From 3041d33e82cedbfed3b5a0effc565b01f5198b2e Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 19 Jan 2026 15:16:19 +0000 Subject: [PATCH 01/17] feat: Add monorepo and nested project support with grouped view - Implement grouped project view with 1-level nesting for organized project categories - Add nested folder support allowing parent directories with sub-projects to display as category headers - Non-selectable category headers show sub-project counts and visual hierarchy - Support for monorepo patterns with projects organized under category folders - New Project struct fields: Depth (nesting level), SubProjectCount, IsGroup, Expanded - Add ViewGroup for navigating into grouped projects - Display child projects indented under parent groups with intuitive UI - Navigation between main view and group view with proper back/forward handling - Comprehensive documentation of grouped project feature in NESTED_PROJECTS.md --- NESTED_PROJECTS.md | 155 +++++++++++++++++ internal/app/app.go | 180 +++++++++++++++++++- internal/project/scanner.go | 265 ++++++++++++++++++++++++----- internal/project/scanner_test.go | 15 +- internal/tui/views/project_list.go | 175 ++++++++++++++----- 5 files changed, 695 insertions(+), 95 deletions(-) create mode 100644 NESTED_PROJECTS.md diff --git a/NESTED_PROJECTS.md b/NESTED_PROJECTS.md new file mode 100644 index 0000000..b45db26 --- /dev/null +++ b/NESTED_PROJECTS.md @@ -0,0 +1,155 @@ +# Nested Project Support + +## Overview + +The project scanner now supports **grouped project view** with 1-level nesting, allowing organized project categories. + +## Features Implemented + +### 1. **Grouped View with Category Headers** +- Scans 1 level deep to detect sub-projects +- Parent folders with sub-projects become non-selectable category headers +- Only immediate children are shown (no deep recursion) +- If no nesting exists, shows normal flat list +- Example structure: + ``` + nested-dir (category - not selectable) + ├── project1 + └── project2 + webdev (category - not selectable) + ├── frontend + ├── backend + └── shared + standalone-project (regular project) + ``` + +### 2. **Project Structure Enhancements** +New fields added to `Project` struct: +- `Depth` (int): Tracks nesting level (0 = category/top-level, 1 = nested project) +- `SubProjectCount` (int): Number of immediate sub-projects (determines if it's a category) + +### 3. **UI Display Enhancements** +- **Category Headers**: Parent projects with children shown in gray, marked with ▼ +- **Non-selectable**: Categories cannot be selected - only actual projects +- **Indentation**: Child projects indented with ` ` (2 spaces) +- **Sub-project Count**: Categories display count (e.g., `nested-dir (3)`) +- **Smart Detection**: Folders without sub-projects shown as regular selectable projects + +### 4. **Configuration** +New config option in `DisplayConfig`: +```json +{ + "display": { + "maxScanDepth": 1 + } +} +``` + +Set to `0` for completely flat view (no scanning of subdirectories). + +## Example Output + +``` +▼ gamedev (2) [category - not selectable] + ▸ gamedev/unity-game 🎮 C# develop + ▸ gamedev/godot-project 🐍 Python feature/ui +▼ webdev (3) [category - not selectable] + ▸ webdev/frontend ⚛️ TypeScript main + ▸ webdev/backend 🟢 Node.js main + ▸ webdev/shared 🟨 JavaScript main +▸ standalone 🔷 Go main* +``` + +## Use Cases + +1. **Organized Repos**: Group related projects under category folders +2. **Project Categories**: Separate by type (gamedev, webdev, mobile, etc.) +3. **Monorepos**: Show multiple sub-projects within a parent directory +4. **Mixed Structure**: Some projects standalone, others grouped + +## Behavior + +### With Nested Folders +When a folder contains sub-projects: +- Parent folder shown as **category header** (gray, non-selectable) +- Marked with ▼ instead of ▸ +- Shows count of sub-projects: `(3)` +- Children shown indented below +- Only children are selectable + +### Without Nested Folders +When a folder has no sub-projects: +- Shown as regular project (selectable) +- No special formatting +- Normal white text with ▸ marker + +### Configuration + +### Toggle Grouped View + +Control whether to use grouped view or flat view: + +```json +{ + "display": { + "showNestedProjects": true // Enable grouped view (default) + } +} +``` + +**When `showNestedProjects: true` (Grouped View):** +- Scans 1 level deep +- Parent folders with sub-projects become category headers +- Shows organized hierarchy + +**When `showNestedProjects: false` (Flat View):** +- Only shows root-level directories +- No grouping or categories +- Simple flat list of all projects +- Good for structure like: + ``` + code/ + ├── project1/ + ├── project2/ + └── project3/ + ``` + +### Default Settings +- `showNestedProjects`: true (grouped view enabled) +- `maxScanDepth`: 1 (one level of nesting) + +### Advanced: Custom Depth + +For grouped view, you can control scan depth: + +Edit your config file: +```json +{ + "display": { + "showNestedProjects": true, + "maxScanDepth": 2 // Scan 2 levels deep (categories can have categories) + } +} +``` + +Note: Setting `showNestedProjects: false` overrides `maxScanDepth` and forces flat view. + +## Implementation Details + +### Scanner Changes +- `Scan()` now calls `scanRecursive()` with depth tracking +- Parent projects are scanned first, followed by children +- Each level respects exclusion patterns and hidden directory settings + +### Performance Considerations +- Depth limit prevents infinite recursion +- Excluded directories (`.git`, `node_modules`, etc.) are skipped at all levels +- Only directories are scanned (files are ignored) + +## Future Enhancements + +Potential improvements: +- Collapsible tree view (expand/collapse parent projects) +- Recursive actions (apply operation to all sub-projects) +- Custom depth per project category +- Search filtering that respects hierarchy diff --git a/internal/app/app.go b/internal/app/app.go index 7648367..5763b67 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 @@ -206,6 +210,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 +221,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 +283,56 @@ 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.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 +343,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 +364,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 @@ -322,14 +423,24 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case ViewNewProject: switch { case key.Matches(msg, m.keys.Back): - m.view = ViewProjects + // Go back to the appropriate view + 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 +521,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 +549,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 +573,9 @@ func (m Model) View() string { case ViewProjects: return m.renderProjectsView() + case ViewGroup: + return m.renderGroupView() + case ViewActions: return m.renderActionsView() @@ -499,7 +620,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/expand • s: sort • n: new • q: quit") errorMsg := "" if m.err != nil { @@ -523,6 +644,46 @@ func (m Model) renderProjectsView() string { ) } +// renderGroupView renders the group projects list view +func (m Model) renderGroupView() string { + 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 project • 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 +842,11 @@ 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) +} + // 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 { diff --git a/internal/project/scanner.go b/internal/project/scanner.go index 5138784..72b3e26 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,114 @@ 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) + } + } + // If neither a project nor a group, skip it + } + + return projects, nil +} + +// findChildProjects finds all projects in a directory (1 level only) +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 err != nil { - // Skip projects we can't read + if s.isExcluded(entry.Name()) { continue } - projects = append(projects, project) + childPath := filepath.Join(dirPath, entry.Name()) + + if isProjectRoot(childPath) { + proj, err := s.scanProject(entry.Name(), childPath, 1) + if err != nil { + continue + } + 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 +214,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 +256,94 @@ 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: + // Groups without language sort after projects with language + langI := pi.Language + langJ := pj.Language + if pi.IsGroup { + langI = "zzz" // Sort groups to end when sorting by language + } + if pj.IsGroup { + langJ = "zzz" + } + if langI == langJ { + return pi.Name < pj.Name + } + return langI < langJ + 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/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 From 2e4dd82d08e3b7e339b25222200d4768ef349e0e Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 19 Jan 2026 15:19:06 +0000 Subject: [PATCH 02/17] fix: Add IsFeatureEnabled method to Config for feature interface compatibility - Implement FeatureConfig interface to allow Config to work with feature system - Stub implementation returns false for all features (ready for future enhancement) --- internal/config/config.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index ffb608f..1abce8e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -209,3 +209,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 +} From 65c589b938e23a377e3b712f4b343f5f01203fb2 Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 19 Jan 2026 15:41:40 +0000 Subject: [PATCH 03/17] fix: Show all directories in project scanner, not just those with project indicators Previously only directories containing project files (.git, package.json, etc.) were displayed in project listings. This caused newly created empty directories to be invisible until they contained project files, creating the appearance of a caching issue. Now all directories are shown in listings, with empty directories marked appropriately (no language, no git status) to distinguish them from actual projects while still being selectable for potential project initialization. Resolves issue where 'when I create a new folder and re-run proj I cant see the folder' --- internal/project/scanner.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/project/scanner.go b/internal/project/scanner.go index 72b3e26..4e03c0c 100644 --- a/internal/project/scanner.go +++ b/internal/project/scanner.go @@ -120,8 +120,21 @@ func (s *Scanner) scanWithGroups(basePath string) ([]*Project, error) { 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) } - // If neither a project nor a group, skip it + // No longer skip any directories - show all } return projects, nil From 2d058eb1b09562469f2dd19fff3ee2d3d7c4e4fe Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 19 Jan 2026 16:04:57 +0000 Subject: [PATCH 04/17] fix: Refresh group view after project creation Previously when creating a new project while viewing a group, the shouldReload flag would only refresh the main projects list but not the group projects view. This caused newly created projects within groups to not appear until manually navigating back and forth. Added loadProjectsAndRefreshGroup() method that: - Reloads the main projects list - Refreshes group projects if currently viewing a group - Updates both project list and group list models - Ensures UI consistency after project creation within groups --- internal/app/app.go | 63 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 5763b67..1ecd16a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -96,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 { @@ -157,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 @@ -422,8 +428,8 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case ViewNewProject: switch { - case key.Matches(msg, m.keys.Back): - // Go back to the appropriate view + 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 { @@ -847,6 +853,40 @@ func (m Model) loadProjects() tea.Cmd { return loadProjects(m.config) } +// loadProjectsAndRefreshGroup loads projects and refreshes group view if currently in a group +func (m *Model) loadProjectsAndRefreshGroup() tea.Cmd { + return func() tea.Msg { + // Load projects first + scanner := project.NewScanner(m.config) + projects, err := scanner.Scan(m.config.ReposPath) + if err != nil { + return errMsg(err) + } + + // Sort projects + sortBy := project.SortBy(m.config.Display.SortBy) + projects = project.Sort(projects, sortBy) + + // Update the model's projects + m.projects = projects + + // If we're currently viewing a group, refresh the group projects + if m.selectedGroup != nil { + m.groupProjects = m.getChildProjects(m.selectedGroup.Path) + m.groupList = views.NewGroupListModel(m.groupProjects) + m.updateSizes() + } + + // Update the main project list too + if len(m.projects) > 0 { + m.projectList = views.NewProjectListModel(m.projects) + m.updateSizes() + } + + return projectsLoadedMsg(projects) + } +} + // 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 { @@ -936,13 +976,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 } } } From dff52e5b1507d0c49a196cd06522b777ee1c2ffc Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 19 Jan 2026 16:09:42 +0000 Subject: [PATCH 05/17] fix: Show ALL directories in groups and add F5/r refresh Fixed fundamental issue where groups only showed directories containing project indicators (.git, package.json, etc.), causing most directories to be invisible. Scanner changes: - findChildProjects now shows ALL directories, not just project roots - Empty directories are marked appropriately (no language, no git status) - Consistent behavior with top-level directory scanning New features: - Added F5 and 'r' key bindings to refresh/rescan projects - Works in both main project view and group view - Updated help text to show refresh shortcuts This fixes the issue where gamedev showed only 2 of 6 directories. --- internal/app/app.go | 13 +++++++++++-- internal/project/scanner.go | 22 +++++++++++++++------- internal/tui/keys.go | 23 ++++++++++++++--------- 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 1ecd16a..a3ae648 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -209,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() @@ -299,6 +304,10 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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() @@ -626,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/expand • 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 { @@ -664,7 +673,7 @@ func (m Model) renderGroupView() string { projectCount := tui.SubtitleStyle.Render(fmt.Sprintf("%d projects", len(m.groupProjects))) - help := tui.HelpStyle.Render("↑/↓: navigate • enter: select • n: new project • esc: back • q: quit") + help := tui.HelpStyle.Render("↑/↓: navigate • enter: select • n: new • r/F5: refresh • esc: back • q: quit") return tui.ContainerStyle.Render( lipgloss.JoinVertical( diff --git a/internal/project/scanner.go b/internal/project/scanner.go index 4e03c0c..a3cb7b4 100644 --- a/internal/project/scanner.go +++ b/internal/project/scanner.go @@ -140,7 +140,8 @@ func (s *Scanner) scanWithGroups(basePath string) ([]*Project, error) { return projects, nil } -// findChildProjects finds all projects in a directory (1 level only) +// 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 { @@ -164,13 +165,20 @@ func (s *Scanner) findChildProjects(dirPath string) []*Project { childPath := filepath.Join(dirPath, entry.Name()) - if isProjectRoot(childPath) { - proj, err := s.scanProject(entry.Name(), childPath, 1) - if err != nil { - continue - } - projects = append(projects, proj) + proj, err := s.scanProject(entry.Name(), childPath, 1) + if err != nil { + continue + } + + // 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 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"), + ), } } From 3f4bf43f1f8ef2eaa225f08d63ecf5ce4b50983c Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 19 Jan 2026 16:19:31 +0000 Subject: [PATCH 06/17] docs: Update config docs to reflect actual implementation status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarified which display options are actually implemented vs planned: Working options: - showHiddenDirs: ✅ implemented - sortBy: ✅ implemented Documented but not yet implemented: - showGitStatus: defined in config but not used (always shows) - showLanguage: defined in config but not used (always shows) Not in config struct (planned only): - showNestedProjects: removed from example, noted as always-on - maxScanDepth: removed from example, noted as always 1 Updated NESTED_PROJECTS.md with notes about current hardcoded behavior vs planned configurable options. --- NESTED_PROJECTS.md | 28 ++++++++++++++++++---------- docs/CONFIG.md | 4 ++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/NESTED_PROJECTS.md b/NESTED_PROJECTS.md index b45db26..617b7bf 100644 --- a/NESTED_PROJECTS.md +++ b/NESTED_PROJECTS.md @@ -87,12 +87,20 @@ When a folder has no sub-projects: ### Toggle Grouped View +## Current Behavior + +Nested/grouped projects are **always enabled** with a fixed scan depth of 1 level. The configuration options described below are planned but not yet implemented. + +### Planned Configuration Options + +> **Note:** These options are documented for future implementation. Currently, grouped view is always active. + Control whether to use grouped view or flat view: ```json { "display": { - "showNestedProjects": true // Enable grouped view (default) + "showNestedProjects": true // Enable grouped view (planned, currently always true) } } ``` @@ -102,7 +110,7 @@ Control whether to use grouped view or flat view: - Parent folders with sub-projects become category headers - Shows organized hierarchy -**When `showNestedProjects: false` (Flat View):** +**When `showNestedProjects: false` (Flat View) - NOT YET IMPLEMENTED:** - Only shows root-level directories - No grouping or categories - Simple flat list of all projects @@ -114,26 +122,26 @@ Control whether to use grouped view or flat view: └── project3/ ``` -### Default Settings -- `showNestedProjects`: true (grouped view enabled) -- `maxScanDepth`: 1 (one level of nesting) +### Default Settings (Hardcoded) +- Grouped view: always enabled +- Scan depth: 1 (one level of nesting) + +### Planned: Custom Depth -### Advanced: Custom Depth +> **Note:** `maxScanDepth` configuration is not yet implemented. -For grouped view, you can control scan depth: +For grouped view, you can control scan depth (planned feature): Edit your config file: ```json { "display": { "showNestedProjects": true, - "maxScanDepth": 2 // Scan 2 levels deep (categories can have categories) + "maxScanDepth": 2 // Planned: Scan 2 levels deep } } ``` -Note: Setting `showNestedProjects: false` overrides `maxScanDepth` and forces flat view. - ## Implementation Details ### Scanner Changes diff --git a/docs/CONFIG.md b/docs/CONFIG.md index cb9b32f..1ed470a 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -312,6 +312,8 @@ How to sort projects in the list. Whether to show git branch and dirty status in the project list. +> **Note:** This option is currently defined but not yet implemented. Git status is always shown. + ```json { "display": { @@ -327,6 +329,8 @@ Whether to show git branch and dirty status in the project list. Whether to show detected language in the project list. +> **Note:** This option is currently defined but not yet implemented. Language is always shown. + ```json { "display": { From fc8b4632e05b77492babec94ff3a149d3b834053 Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 19 Jan 2026 16:21:16 +0000 Subject: [PATCH 07/17] refactor: Remove non-implemented config options Removed showGitStatus and showLanguage from: - DisplayConfig struct - DefaultConfig() - viper defaults - CONFIG.md documentation These options were defined but never wired up to the display code. Git status and language are always shown - no config needed. Also cleaned up NESTED_PROJECTS.md to remove misleading 'planned' configuration options that don't exist in the config struct. Display config now only contains working options: - showHiddenDirs: controls hidden directory visibility - sortBy: controls project sort order --- NESTED_PROJECTS.md | 59 +++------------------------------------ docs/CONFIG.md | 38 +------------------------ internal/config/config.go | 6 ---- 3 files changed, 5 insertions(+), 98 deletions(-) diff --git a/NESTED_PROJECTS.md b/NESTED_PROJECTS.md index 617b7bf..e4dde53 100644 --- a/NESTED_PROJECTS.md +++ b/NESTED_PROJECTS.md @@ -83,70 +83,19 @@ When a folder has no sub-projects: - No special formatting - Normal white text with ▸ marker -### Configuration - -### Toggle Grouped View - ## Current Behavior -Nested/grouped projects are **always enabled** with a fixed scan depth of 1 level. The configuration options described below are planned but not yet implemented. - -### Planned Configuration Options - -> **Note:** These options are documented for future implementation. Currently, grouped view is always active. - -Control whether to use grouped view or flat view: - -```json -{ - "display": { - "showNestedProjects": true // Enable grouped view (planned, currently always true) - } -} -``` - -**When `showNestedProjects: true` (Grouped View):** -- Scans 1 level deep -- Parent folders with sub-projects become category headers -- Shows organized hierarchy +Nested/grouped projects are **always enabled** with a fixed scan depth of 1 level. -**When `showNestedProjects: false` (Flat View) - NOT YET IMPLEMENTED:** -- Only shows root-level directories -- No grouping or categories -- Simple flat list of all projects -- Good for structure like: - ``` - code/ - ├── project1/ - ├── project2/ - └── project3/ - ``` - -### Default Settings (Hardcoded) - Grouped view: always enabled - Scan depth: 1 (one level of nesting) - -### Planned: Custom Depth - -> **Note:** `maxScanDepth` configuration is not yet implemented. - -For grouped view, you can control scan depth (planned feature): - -Edit your config file: -```json -{ - "display": { - "showNestedProjects": true, - "maxScanDepth": 2 // Planned: Scan 2 levels deep - } -} -``` +- All directories within a group are shown (not just those with project indicators) ## Implementation Details ### Scanner Changes -- `Scan()` now calls `scanRecursive()` with depth tracking -- Parent projects are scanned first, followed by children +- `Scan()` calls `scanWithGroups()` to detect parent/child relationships +- `findChildProjects()` finds all directories within a group (1 level deep) - Each level respects exclusion patterns and hidden directory settings ### Performance Considerations diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 1ed470a..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,40 +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. - -> **Note:** This option is currently defined but not yet implemented. Git status is always shown. - -```json -{ - "display": { - "showGitStatus": false - } -} -``` - -#### display.showLanguage - -**Type:** `boolean` -**Default:** `true` - -Whether to show detected language in the project list. - -> **Note:** This option is currently defined but not yet implemented. Language is always shown. - -```json -{ - "display": { - "showLanguage": false - } -} -``` - --- ### excludePatterns diff --git a/internal/config/config.go b/internal/config/config.go index 1abce8e..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) From 0034bcb3af8e7b970bc5e299ee35052ec1896c1e Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 19 Jan 2026 16:25:39 +0000 Subject: [PATCH 08/17] feat: Enhanced multi-shell integration support Added comprehensive shell integration for multiple shells: New shell-integration.sh script: - Auto-detects shell type (bash, zsh, fish, nushell, elvish, powershell) - Automatically adds integration function to appropriate config file - Handles edge cases like existing functions and directory creation Enhanced install.sh: - Added fish shell support with proper syntax - Improved shell detection and setup flow - Better error handling for unsupported shells Updated documentation: - README.md: Added fish shell integration example - INSTALL.md: Added 6 shell examples plus auto-setup instructions - Covers bash, zsh, fish, nushell, elvish, powershell This addresses the issue where fish users had to manually figure out the correct function syntax and provides broader shell ecosystem support. --- NESTED_PROJECTS.md | 112 ----------------------- README.md | 18 +++- docs/INSTALL.md | 66 ++++++++++++- go.mod | 2 +- scripts/install.sh | 138 +++++++++++++++++++++++----- scripts/shell-integration.sh | 173 +++++++++++++++++++++++++++++++++++ 6 files changed, 369 insertions(+), 140 deletions(-) delete mode 100644 NESTED_PROJECTS.md create mode 100755 scripts/shell-integration.sh diff --git a/NESTED_PROJECTS.md b/NESTED_PROJECTS.md deleted file mode 100644 index e4dde53..0000000 --- a/NESTED_PROJECTS.md +++ /dev/null @@ -1,112 +0,0 @@ -# Nested Project Support - -## Overview - -The project scanner now supports **grouped project view** with 1-level nesting, allowing organized project categories. - -## Features Implemented - -### 1. **Grouped View with Category Headers** -- Scans 1 level deep to detect sub-projects -- Parent folders with sub-projects become non-selectable category headers -- Only immediate children are shown (no deep recursion) -- If no nesting exists, shows normal flat list -- Example structure: - ``` - nested-dir (category - not selectable) - ├── project1 - └── project2 - webdev (category - not selectable) - ├── frontend - ├── backend - └── shared - standalone-project (regular project) - ``` - -### 2. **Project Structure Enhancements** -New fields added to `Project` struct: -- `Depth` (int): Tracks nesting level (0 = category/top-level, 1 = nested project) -- `SubProjectCount` (int): Number of immediate sub-projects (determines if it's a category) - -### 3. **UI Display Enhancements** -- **Category Headers**: Parent projects with children shown in gray, marked with ▼ -- **Non-selectable**: Categories cannot be selected - only actual projects -- **Indentation**: Child projects indented with ` ` (2 spaces) -- **Sub-project Count**: Categories display count (e.g., `nested-dir (3)`) -- **Smart Detection**: Folders without sub-projects shown as regular selectable projects - -### 4. **Configuration** -New config option in `DisplayConfig`: -```json -{ - "display": { - "maxScanDepth": 1 - } -} -``` - -Set to `0` for completely flat view (no scanning of subdirectories). - -## Example Output - -``` -▼ gamedev (2) [category - not selectable] - ▸ gamedev/unity-game 🎮 C# develop - ▸ gamedev/godot-project 🐍 Python feature/ui -▼ webdev (3) [category - not selectable] - ▸ webdev/frontend ⚛️ TypeScript main - ▸ webdev/backend 🟢 Node.js main - ▸ webdev/shared 🟨 JavaScript main -▸ standalone 🔷 Go main* -``` - -## Use Cases - -1. **Organized Repos**: Group related projects under category folders -2. **Project Categories**: Separate by type (gamedev, webdev, mobile, etc.) -3. **Monorepos**: Show multiple sub-projects within a parent directory -4. **Mixed Structure**: Some projects standalone, others grouped - -## Behavior - -### With Nested Folders -When a folder contains sub-projects: -- Parent folder shown as **category header** (gray, non-selectable) -- Marked with ▼ instead of ▸ -- Shows count of sub-projects: `(3)` -- Children shown indented below -- Only children are selectable - -### Without Nested Folders -When a folder has no sub-projects: -- Shown as regular project (selectable) -- No special formatting -- Normal white text with ▸ marker - -## Current Behavior - -Nested/grouped projects are **always enabled** with a fixed scan depth of 1 level. - -- Grouped view: always enabled -- Scan depth: 1 (one level of nesting) -- All directories within a group are shown (not just those with project indicators) - -## Implementation Details - -### Scanner Changes -- `Scan()` calls `scanWithGroups()` to detect parent/child relationships -- `findChildProjects()` finds all directories within a group (1 level deep) -- Each level respects exclusion patterns and hidden directory settings - -### Performance Considerations -- Depth limit prevents infinite recursion -- Excluded directories (`.git`, `node_modules`, etc.) are skipped at all levels -- Only directories are scanned (files are ignored) - -## Future Enhancements - -Potential improvements: -- Collapsible tree view (expand/collapse parent projects) -- Recursive actions (apply operation to all sub-projects) -- Custom depth per project category -- Search filtering that respects hierarchy diff --git a/README.md b/README.md index 7b46697..a0c2578 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,9 @@ curl -sSL https://raw.githubusercontent.com/s33g/proj/main/scripts/install.sh | ### Shell Integration (Recommended) -To enable the "Change Directory" feature, add this to your `~/.bashrc` or `~/.zshrc`: +To enable the "Change Directory" feature, add this to your shell configuration: +**For Bash/Zsh (`~/.bashrc` or `~/.zshrc`):** ```bash # proj - TUI project navigator proj() { @@ -89,7 +90,20 @@ proj() { } ``` -Then reload your shell: `source ~/.bashrc` +**For Fish (`~/.config/fish/config.fish`):** +```fish +# proj - TUI project navigator +function proj + set output (mktemp) + env PROJ_CD_FILE="$output" ~/.local/bin/proj $argv + if test -s "$output" + cd (cat "$output") + end + rm -f "$output" +end +``` + +Then reload your shell: `source ~/.bashrc` (or restart terminal) ## Usage diff --git a/docs/INSTALL.md b/docs/INSTALL.md index b475209..281ed47 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -161,6 +161,22 @@ Replace `~/code` with your actual projects directory. Shell integration enables the "Change Directory" action to actually change your terminal's current directory. +#### Automatic Setup (Easiest) + +Use the provided shell integration script: + +```bash +# Auto-detect your shell and set up integration +curl -sSL https://raw.githubusercontent.com/s33g/proj/main/scripts/shell-integration.sh | bash + +# Or download and run locally +wget https://raw.githubusercontent.com/s33g/proj/main/scripts/shell-integration.sh +chmod +x shell-integration.sh +./shell-integration.sh +``` + +#### Manual Setup + **For Bash (`~/.bashrc`):** ```bash @@ -194,8 +210,8 @@ proj() { ```fish # proj - TUI project navigator function proj - set -l output (mktemp) - PROJ_CD_FILE="$output" command ~/.local/bin/proj $argv + set output (mktemp) + env PROJ_CD_FILE="$output" ~/.local/bin/proj $argv if test -s "$output" cd (cat "$output") end @@ -203,6 +219,52 @@ function proj end ``` +**For Nushell (`~/.config/nushell/config.nu`):** + +```nu +# proj - TUI project navigator +def proj [...args] { + let output = (mktemp) + with-env [PROJ_CD_FILE $output] { ^~/.local/bin/proj ...$args } + if ($output | path exists) and (($output | open | str length) > 0) { + cd ($output | open) + } + rm $output +} +``` + +**For Elvish (`~/.elvish/rc.elv`):** + +```elvish +# proj - TUI project navigator +fn proj {|@args| + var output = (mktemp) + E:PROJ_CD_FILE=$output ~/.local/bin/proj $@args + if (test -s $output) { + cd (slurp < $output) + } + rm -f $output +} +``` + +**For PowerShell (`$PROFILE`):** + +```powershell +# proj - TUI project navigator +function proj { + $output = New-TemporaryFile + $env:PROJ_CD_FILE = $output.FullName + & "~/.local/bin/proj" @args + if (Test-Path $output -PathType Leaf) { + $path = Get-Content $output -Raw + if ($path.Trim()) { + Set-Location $path.Trim() + } + } + Remove-Item $output -ErrorAction SilentlyContinue +} +``` + Then reload your shell: ```bash source ~/.bashrc # or ~/.zshrc, or restart terminal 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/scripts/install.sh b/scripts/install.sh index fe59657..12d2a01 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -131,55 +131,147 @@ setup_shell_integration() { 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 bash) RC_FILE="$HOME/.bashrc" + show_bash_integration ;; zsh) - RC_FILE="$HOME/.zshrc" + RC_FILE="$HOME/.zshrc" + show_zsh_integration + ;; + fish) + RC_FILE="$HOME/.config/fish/config.fish" + show_fish_integration ;; *) RC_FILE="" + show_generic_integration ;; esac - if [ -n "$RC_FILE" ]; then + if [ -n "$RC_FILE" ] && [ "$SHELL_NAME" != "fish" ]; 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" + add_shell_integration_$SHELL_NAME success "Added to $RC_FILE" warn "Run 'source $RC_FILE' or restart your shell to apply changes" fi + elif [ "$SHELL_NAME" = "fish" ]; then + echo "Detected shell: fish" + 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 + add_fish_integration + success "Added to $RC_FILE" + warn "Restart your shell to apply changes" + fi fi } +show_bash_integration() { + 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 "" +} + +show_zsh_integration() { + 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 "" +} + +show_fish_integration() { + echo " # proj - TUI project navigator" + echo " function proj" + echo " set output (mktemp)" + echo " env PROJ_CD_FILE=\"\$output\" $INSTALL_DIR/proj \$argv" + echo "" + echo " if test -s \"\$output\"" + echo " cd (cat \"\$output\")" + echo " end" + echo "" + echo " rm -f \"\$output\"" + echo " end" + echo "" +} + +show_generic_integration() { + echo " Unknown shell: $SHELL_NAME" + echo " Please refer to the documentation for shell integration:" + echo " https://github.com/${REPO}#shell-integration" + echo "" + echo " Or adapt one of these examples:" + echo "" + show_bash_integration +} + +add_shell_integration_bash() { + 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" +} + +add_shell_integration_zsh() { + 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" +} + +add_fish_integration() { + mkdir -p "$(dirname "$RC_FILE")" + echo "" >> "$RC_FILE" + echo "# proj - TUI project navigator" >> "$RC_FILE" + echo "function proj" >> "$RC_FILE" + echo " set output (mktemp)" >> "$RC_FILE" + echo " env PROJ_CD_FILE=\"\$output\" $INSTALL_DIR/proj \$argv" >> "$RC_FILE" + echo "" >> "$RC_FILE" + echo " if test -s \"\$output\"" >> "$RC_FILE" + echo " cd (cat \"\$output\")" >> "$RC_FILE" + echo " end" >> "$RC_FILE" + echo "" >> "$RC_FILE" + echo " rm -f \"\$output\"" >> "$RC_FILE" + echo "end" >> "$RC_FILE" +} + # Check PATH check_path() { echo "" diff --git a/scripts/shell-integration.sh b/scripts/shell-integration.sh new file mode 100755 index 0000000..52f2036 --- /dev/null +++ b/scripts/shell-integration.sh @@ -0,0 +1,173 @@ +#!/bin/bash +# Shell integration setup script for proj +# Usage: ./shell-integration.sh [shell] [install_dir] + +set -e + +SHELL_TYPE="${1:-auto}" +INSTALL_DIR="${2:-$HOME/.local/bin}" + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +info() { + echo -e "${GREEN}ℹ${NC} $1" +} + +warn() { + echo -e "${YELLOW}⚠${NC} $1" +} + +success() { + echo -e "${GREEN}✓${NC} $1" +} + +# Auto-detect shell if not specified +if [ "$SHELL_TYPE" = "auto" ]; then + SHELL_TYPE=$(basename "$SHELL") +fi + +case "$SHELL_TYPE" in + bash) + RC_FILE="$HOME/.bashrc" + FUNCTION_DEF="# proj - TUI project navigator +proj() { + local output=\$(mktemp) + PROJ_CD_FILE=\"\$output\" command $INSTALL_DIR/proj \"\$@\" + if [ -s \"\$output\" ]; then + cd \"\$(cat \"\$output\")\" + fi + rm -f \"\$output\" +}" + SOURCE_CMD="source ~/.bashrc" + ;; + zsh) + RC_FILE="$HOME/.zshrc" + FUNCTION_DEF="# proj - TUI project navigator +proj() { + local output=\$(mktemp) + PROJ_CD_FILE=\"\$output\" command $INSTALL_DIR/proj \"\$@\" + if [ -s \"\$output\" ]; then + cd \"\$(cat \"\$output\")\" + fi + rm -f \"\$output\" +}" + SOURCE_CMD="source ~/.zshrc" + ;; + fish) + RC_FILE="$HOME/.config/fish/config.fish" + FUNCTION_DEF="# proj - TUI project navigator +function proj + set output (mktemp) + env PROJ_CD_FILE=\"\$output\" $INSTALL_DIR/proj \$argv + + if test -s \"\$output\" + cd (cat \"\$output\") + end + + rm -f \"\$output\" +end" + SOURCE_CMD="restart your terminal" + ;; + nushell|nu) + RC_FILE="$HOME/.config/nushell/config.nu" + FUNCTION_DEF="# proj - TUI project navigator +def proj [...args] { + let output = (mktemp) + with-env [PROJ_CD_FILE $output] { ^$INSTALL_DIR/proj ...\$args } + if (\$output | path exists) and ((\$output | open | str length) > 0) { + cd (\$output | open) + } + rm \$output +}" + SOURCE_CMD="restart your terminal" + ;; + elvish) + RC_FILE="$HOME/.elvish/rc.elv" + FUNCTION_DEF="# proj - TUI project navigator +fn proj {|@args| + var output = (mktemp) + E:PROJ_CD_FILE=\$output $INSTALL_DIR/proj \$@args + if (test -s \$output) { + cd (slurp < \$output) + } + rm -f \$output +}" + SOURCE_CMD="restart your terminal" + ;; + powershell|pwsh) + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then + RC_FILE="$HOME/Documents/PowerShell/Microsoft.PowerShell_profile.ps1" + else + RC_FILE="$HOME/.config/powershell/Microsoft.PowerShell_profile.ps1" + fi + FUNCTION_DEF="# proj - TUI project navigator +function proj { + \$output = New-TemporaryFile + \$env:PROJ_CD_FILE = \$output.FullName + & \"$INSTALL_DIR/proj\" @args + if (Test-Path \$output -PathType Leaf) { + \$path = Get-Content \$output -Raw + if (\$path.Trim()) { + Set-Location \$path.Trim() + } + } + Remove-Item \$output -ErrorAction SilentlyContinue +}" + SOURCE_CMD="restart your terminal or run: . \$PROFILE" + ;; + *) + echo "Unsupported shell: $SHELL_TYPE" + echo "Supported shells: bash, zsh, fish, nushell, elvish, powershell" + echo "" + echo "For other shells, adapt this pattern:" + echo "1. Create a temporary file with mktemp" + echo "2. Set PROJ_CD_FILE environment variable to that file" + echo "3. Run proj with your arguments" + echo "4. If the file exists and has content, cd to that path" + echo "5. Clean up the temporary file" + exit 1 + ;; +esac + +echo "Setting up proj shell integration for: $SHELL_TYPE" +echo "Configuration file: $RC_FILE" +echo "" + +# Check if function already exists +if [ -f "$RC_FILE" ] && grep -q "proj.*TUI project navigator" "$RC_FILE"; then + warn "proj function already exists in $RC_FILE" + echo "" + read -p "Replace existing function? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Keeping existing function" + exit 0 + fi + + # Remove existing function (simplified - removes from # proj comment to next function/end of file) + case "$SHELL_TYPE" in + fish|nushell|elvish) + # For these shells, we'll append and let user clean up manually if needed + ;; + *) + # Remove between # proj comment and next empty line or end of function + sed -i '/# proj - TUI project navigator/,/^}/d' "$RC_FILE" 2>/dev/null || true + ;; + esac +fi + +# Create directory if it doesn't exist +mkdir -p "$(dirname "$RC_FILE")" + +# Add the function +echo "" >> "$RC_FILE" +echo "$FUNCTION_DEF" >> "$RC_FILE" + +success "Added proj shell integration to $RC_FILE" +echo "" +info "To activate the changes, $SOURCE_CMD" +echo "" +echo "Test with: proj --help" \ No newline at end of file From 3374a55d4b1dfcf04e6ac579717e22ef4303a866 Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 19 Jan 2026 17:47:41 +0000 Subject: [PATCH 09/17] refactor: Remove shell integration setup and documentation Removed all shell integration setup functionality: - Deleted scripts/shell-integration.sh (173 lines) - Removed shell integration functions from scripts/install.sh (149 lines) - Removed shell integration documentation from docs/INSTALL.md (115 lines) - Removed shell integration examples from README.md (32 lines) Total reduction: 468 lines This simplifies the project by removing complex multi-shell integration setup that was adding maintenance overhead. --- README.md | 32 ------- docs/INSTALL.md | 115 +---------------------- scripts/install.sh | 149 ------------------------------ scripts/shell-integration.sh | 173 ----------------------------------- 4 files changed, 1 insertion(+), 468 deletions(-) delete mode 100755 scripts/shell-integration.sh diff --git a/README.md b/README.md index a0c2578..0c0033c 100644 --- a/README.md +++ b/README.md @@ -73,38 +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 shell configuration: - -**For Bash/Zsh (`~/.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" -} -``` - -**For Fish (`~/.config/fish/config.fish`):** -```fish -# proj - TUI project navigator -function proj - set output (mktemp) - env PROJ_CD_FILE="$output" ~/.local/bin/proj $argv - if test -s "$output" - cd (cat "$output") - end - rm -f "$output" -end -``` - -Then reload your shell: `source ~/.bashrc` (or restart terminal) - ## Usage ### Keyboard Shortcuts diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 281ed47..b92cdf1 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -157,120 +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. - -#### Automatic Setup (Easiest) - -Use the provided shell integration script: - -```bash -# Auto-detect your shell and set up integration -curl -sSL https://raw.githubusercontent.com/s33g/proj/main/scripts/shell-integration.sh | bash - -# Or download and run locally -wget https://raw.githubusercontent.com/s33g/proj/main/scripts/shell-integration.sh -chmod +x shell-integration.sh -./shell-integration.sh -``` - -#### Manual Setup - -**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 output (mktemp) - env PROJ_CD_FILE="$output" ~/.local/bin/proj $argv - if test -s "$output" - cd (cat "$output") - end - rm -f "$output" -end -``` - -**For Nushell (`~/.config/nushell/config.nu`):** - -```nu -# proj - TUI project navigator -def proj [...args] { - let output = (mktemp) - with-env [PROJ_CD_FILE $output] { ^~/.local/bin/proj ...$args } - if ($output | path exists) and (($output | open | str length) > 0) { - cd ($output | open) - } - rm $output -} -``` - -**For Elvish (`~/.elvish/rc.elv`):** - -```elvish -# proj - TUI project navigator -fn proj {|@args| - var output = (mktemp) - E:PROJ_CD_FILE=$output ~/.local/bin/proj $@args - if (test -s $output) { - cd (slurp < $output) - } - rm -f $output -} -``` - -**For PowerShell (`$PROFILE`):** - -```powershell -# proj - TUI project navigator -function proj { - $output = New-TemporaryFile - $env:PROJ_CD_FILE = $output.FullName - & "~/.local/bin/proj" @args - if (Test-Path $output -PathType Leaf) { - $path = Get-Content $output -Raw - if ($path.Trim()) { - Set-Location $path.Trim() - } - } - Remove-Item $output -ErrorAction SilentlyContinue -} -``` - -Then reload your shell: -```bash -source ~/.bashrc # or ~/.zshrc, or restart terminal -``` - -### 6. Test Everything +### 5. Test Everything ```bash # Launch the TUI diff --git a/scripts/install.sh b/scripts/install.sh index 12d2a01..542fce1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -124,154 +124,6 @@ install_binary() { success "Installed to $INSTALL_DIR/proj" } -# 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 "" - - # Detect shell - SHELL_NAME=$(basename "$SHELL") - - case "$SHELL_NAME" in - bash) - RC_FILE="$HOME/.bashrc" - show_bash_integration - ;; - zsh) - RC_FILE="$HOME/.zshrc" - show_zsh_integration - ;; - fish) - RC_FILE="$HOME/.config/fish/config.fish" - show_fish_integration - ;; - *) - RC_FILE="" - show_generic_integration - ;; - esac - - if [ -n "$RC_FILE" ] && [ "$SHELL_NAME" != "fish" ]; 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 - add_shell_integration_$SHELL_NAME - success "Added to $RC_FILE" - warn "Run 'source $RC_FILE' or restart your shell to apply changes" - fi - elif [ "$SHELL_NAME" = "fish" ]; then - echo "Detected shell: fish" - 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 - add_fish_integration - success "Added to $RC_FILE" - warn "Restart your shell to apply changes" - fi - fi -} - -show_bash_integration() { - 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 "" -} - -show_zsh_integration() { - 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 "" -} - -show_fish_integration() { - echo " # proj - TUI project navigator" - echo " function proj" - echo " set output (mktemp)" - echo " env PROJ_CD_FILE=\"\$output\" $INSTALL_DIR/proj \$argv" - echo "" - echo " if test -s \"\$output\"" - echo " cd (cat \"\$output\")" - echo " end" - echo "" - echo " rm -f \"\$output\"" - echo " end" - echo "" -} - -show_generic_integration() { - echo " Unknown shell: $SHELL_NAME" - echo " Please refer to the documentation for shell integration:" - echo " https://github.com/${REPO}#shell-integration" - echo "" - echo " Or adapt one of these examples:" - echo "" - show_bash_integration -} - -add_shell_integration_bash() { - 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" -} - -add_shell_integration_zsh() { - 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" -} - -add_fish_integration() { - mkdir -p "$(dirname "$RC_FILE")" - echo "" >> "$RC_FILE" - echo "# proj - TUI project navigator" >> "$RC_FILE" - echo "function proj" >> "$RC_FILE" - echo " set output (mktemp)" >> "$RC_FILE" - echo " env PROJ_CD_FILE=\"\$output\" $INSTALL_DIR/proj \$argv" >> "$RC_FILE" - echo "" >> "$RC_FILE" - echo " if test -s \"\$output\"" >> "$RC_FILE" - echo " cd (cat \"\$output\")" >> "$RC_FILE" - echo " end" >> "$RC_FILE" - echo "" >> "$RC_FILE" - echo " rm -f \"\$output\"" >> "$RC_FILE" - echo "end" >> "$RC_FILE" -} - # Check PATH check_path() { echo "" @@ -299,7 +151,6 @@ main() { get_latest_version install_binary check_path - setup_shell_integration echo "" echo "╔═══════════════════════════════════════╗" diff --git a/scripts/shell-integration.sh b/scripts/shell-integration.sh deleted file mode 100755 index 52f2036..0000000 --- a/scripts/shell-integration.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/bin/bash -# Shell integration setup script for proj -# Usage: ./shell-integration.sh [shell] [install_dir] - -set -e - -SHELL_TYPE="${1:-auto}" -INSTALL_DIR="${2:-$HOME/.local/bin}" - -# Colors -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -info() { - echo -e "${GREEN}ℹ${NC} $1" -} - -warn() { - echo -e "${YELLOW}⚠${NC} $1" -} - -success() { - echo -e "${GREEN}✓${NC} $1" -} - -# Auto-detect shell if not specified -if [ "$SHELL_TYPE" = "auto" ]; then - SHELL_TYPE=$(basename "$SHELL") -fi - -case "$SHELL_TYPE" in - bash) - RC_FILE="$HOME/.bashrc" - FUNCTION_DEF="# proj - TUI project navigator -proj() { - local output=\$(mktemp) - PROJ_CD_FILE=\"\$output\" command $INSTALL_DIR/proj \"\$@\" - if [ -s \"\$output\" ]; then - cd \"\$(cat \"\$output\")\" - fi - rm -f \"\$output\" -}" - SOURCE_CMD="source ~/.bashrc" - ;; - zsh) - RC_FILE="$HOME/.zshrc" - FUNCTION_DEF="# proj - TUI project navigator -proj() { - local output=\$(mktemp) - PROJ_CD_FILE=\"\$output\" command $INSTALL_DIR/proj \"\$@\" - if [ -s \"\$output\" ]; then - cd \"\$(cat \"\$output\")\" - fi - rm -f \"\$output\" -}" - SOURCE_CMD="source ~/.zshrc" - ;; - fish) - RC_FILE="$HOME/.config/fish/config.fish" - FUNCTION_DEF="# proj - TUI project navigator -function proj - set output (mktemp) - env PROJ_CD_FILE=\"\$output\" $INSTALL_DIR/proj \$argv - - if test -s \"\$output\" - cd (cat \"\$output\") - end - - rm -f \"\$output\" -end" - SOURCE_CMD="restart your terminal" - ;; - nushell|nu) - RC_FILE="$HOME/.config/nushell/config.nu" - FUNCTION_DEF="# proj - TUI project navigator -def proj [...args] { - let output = (mktemp) - with-env [PROJ_CD_FILE $output] { ^$INSTALL_DIR/proj ...\$args } - if (\$output | path exists) and ((\$output | open | str length) > 0) { - cd (\$output | open) - } - rm \$output -}" - SOURCE_CMD="restart your terminal" - ;; - elvish) - RC_FILE="$HOME/.elvish/rc.elv" - FUNCTION_DEF="# proj - TUI project navigator -fn proj {|@args| - var output = (mktemp) - E:PROJ_CD_FILE=\$output $INSTALL_DIR/proj \$@args - if (test -s \$output) { - cd (slurp < \$output) - } - rm -f \$output -}" - SOURCE_CMD="restart your terminal" - ;; - powershell|pwsh) - if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then - RC_FILE="$HOME/Documents/PowerShell/Microsoft.PowerShell_profile.ps1" - else - RC_FILE="$HOME/.config/powershell/Microsoft.PowerShell_profile.ps1" - fi - FUNCTION_DEF="# proj - TUI project navigator -function proj { - \$output = New-TemporaryFile - \$env:PROJ_CD_FILE = \$output.FullName - & \"$INSTALL_DIR/proj\" @args - if (Test-Path \$output -PathType Leaf) { - \$path = Get-Content \$output -Raw - if (\$path.Trim()) { - Set-Location \$path.Trim() - } - } - Remove-Item \$output -ErrorAction SilentlyContinue -}" - SOURCE_CMD="restart your terminal or run: . \$PROFILE" - ;; - *) - echo "Unsupported shell: $SHELL_TYPE" - echo "Supported shells: bash, zsh, fish, nushell, elvish, powershell" - echo "" - echo "For other shells, adapt this pattern:" - echo "1. Create a temporary file with mktemp" - echo "2. Set PROJ_CD_FILE environment variable to that file" - echo "3. Run proj with your arguments" - echo "4. If the file exists and has content, cd to that path" - echo "5. Clean up the temporary file" - exit 1 - ;; -esac - -echo "Setting up proj shell integration for: $SHELL_TYPE" -echo "Configuration file: $RC_FILE" -echo "" - -# Check if function already exists -if [ -f "$RC_FILE" ] && grep -q "proj.*TUI project navigator" "$RC_FILE"; then - warn "proj function already exists in $RC_FILE" - echo "" - read -p "Replace existing function? [y/N] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Keeping existing function" - exit 0 - fi - - # Remove existing function (simplified - removes from # proj comment to next function/end of file) - case "$SHELL_TYPE" in - fish|nushell|elvish) - # For these shells, we'll append and let user clean up manually if needed - ;; - *) - # Remove between # proj comment and next empty line or end of function - sed -i '/# proj - TUI project navigator/,/^}/d' "$RC_FILE" 2>/dev/null || true - ;; - esac -fi - -# Create directory if it doesn't exist -mkdir -p "$(dirname "$RC_FILE")" - -# Add the function -echo "" >> "$RC_FILE" -echo "$FUNCTION_DEF" >> "$RC_FILE" - -success "Added proj shell integration to $RC_FILE" -echo "" -info "To activate the changes, $SOURCE_CMD" -echo "" -echo "Test with: proj --help" \ No newline at end of file From 32a323c4c0dfaa6f6be1125c8d4f6bbe2c4eb996 Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 19 Jan 2026 17:54:02 +0000 Subject: [PATCH 10/17] feat: Add per-shell integration files and improved installer Shell Integration Features: - Created shell-specific integration files (bash, zsh, fish) in scripts/shells/ - Enhanced install.sh to detect current shell and download appropriate integration - Added graceful handling for unsupported shells with helpful error messages - Installer warns but doesn't block when shell is unsupported Documentation Updates: - Added comprehensive shell integration section to docs/INSTALL.md - Enhanced docs/CONTRIBUTING.md with detailed guide for adding new shell support - Updated README.md with shell integration overview and supported shells list - Included links to contribution guide for extending shell support Files Added: - scripts/shells/bash.sh - Bash integration with wrapper function - scripts/shells/zsh.sh - Zsh integration with wrapper function - scripts/shells/fish.fish - Fish integration with wrapper function Enhanced User Experience: - Auto-detection of current shell during installation - Automatic download and setup of shell integration files - Clear instructions for manual setup when auto-setup fails - Helpful links to documentation and contribution guides for unsupported shells --- README.md | 34 ++++++++++++ docs/CONTRIBUTING.md | 102 ++++++++++++++++++++++++++++++++++++ docs/INSTALL.md | 71 ++++++++++++++++++++++++- scripts/install.sh | 109 +++++++++++++++++++++++++++++++++++++++ scripts/shells/bash.sh | 34 ++++++++++++ scripts/shells/fish.fish | 34 ++++++++++++ scripts/shells/zsh.sh | 34 ++++++++++++ 7 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 scripts/shells/bash.sh create mode 100644 scripts/shells/fish.fish create mode 100644 scripts/shells/zsh.sh diff --git a/README.md b/README.md index 0c0033c..cb91d29 100644 --- a/README.md +++ b/README.md @@ -129,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/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 b92cdf1..cb82181 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -247,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 @@ -323,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/scripts/install.sh b/scripts/install.sh index 542fce1..f103461 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -136,6 +136,114 @@ check_path() { fi } +# Setup shell integration +setup_shell_integration() { + echo "" + info "Setting up shell integration..." + + # Detect current shell + CURRENT_SHELL=$(basename "$SHELL" 2>/dev/null || echo "unknown") + + case "$CURRENT_SHELL" in + bash) + info "Detected bash shell" + setup_bash_integration + ;; + zsh) + info "Detected zsh shell" + setup_zsh_integration + ;; + fish) + info "Detected fish shell" + setup_fish_integration + ;; + *) + warn "Shell '$CURRENT_SHELL' is not directly supported" + show_manual_integration_help + ;; + esac +} + +# 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}" +} + +# 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 + 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 + 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 "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 main() { echo "" @@ -151,6 +259,7 @@ main() { get_latest_version install_binary check_path + setup_shell_integration echo "" echo "╔═══════════════════════════════════════╗" 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..8d69b9f --- /dev/null +++ b/scripts/shells/fish.fish @@ -0,0 +1,34 @@ +#!/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 test -f "$proj_dir_file" + set target_dir (cat "$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..94b5990 --- /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 "$@" + + # 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 zsh integration enabled" +} + +# Auto-setup if sourced +if [[ "${ZSH_EVAL_CONTEXT}" == *":file" ]]; then + setup_proj_zsh_integration +fi From 777446f3b3be0f32d3a54ed1f3b43f384b0b07ce Mon Sep 17 00:00:00 2001 From: Charlie Date: Thu, 22 Jan 2026 12:27:16 +0000 Subject: [PATCH 11/17] Update scripts/shells/fish.fish Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/shells/fish.fish | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/shells/fish.fish b/scripts/shells/fish.fish index 8d69b9f..deb5548 100644 --- a/scripts/shells/fish.fish +++ b/scripts/shells/fish.fish @@ -14,8 +14,7 @@ function setup_proj_fish_integration # 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 test -f "$proj_dir_file" - set target_dir (cat "$proj_dir_file") + 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" From 2d64c587e534d9f84175674634205a49d16468d6 Mon Sep 17 00:00:00 2001 From: Charlie Date: Thu, 22 Jan 2026 12:27:38 +0000 Subject: [PATCH 12/17] Update scripts/install.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/install.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index f103461..8ba3703 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -216,15 +216,29 @@ setup_fish_integration() { mkdir -p "$(dirname "$target_file")" if command -v curl >/dev/null 2>&1; then - curl -sSL "$shell_script_url" -o "$target_file" + 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 - wget -qO "$target_file" "$shell_script_url" + 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" } From 262c92a8301fbf3c5f6868ebb4d523cc8b7faac5 Mon Sep 17 00:00:00 2001 From: Charlie Date: Thu, 22 Jan 2026 12:27:59 +0000 Subject: [PATCH 13/17] Update internal/app/app.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/app/app.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/app/app.go b/internal/app/app.go index a3ae648..df92097 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -661,7 +661,10 @@ func (m Model) renderProjectsView() string { // renderGroupView renders the group projects list view func (m Model) renderGroupView() string { - groupName := m.selectedGroup.Name + groupName := "Group" + if m.selectedGroup != nil { + groupName = m.selectedGroup.Name + } header := tui.TitleStyle.Render(fmt.Sprintf("📁 %s", groupName)) content := "" From cef79cf12e58408e84ed61f776e77b4d730acf5e Mon Sep 17 00:00:00 2001 From: Charlie Date: Thu, 22 Jan 2026 12:29:29 +0000 Subject: [PATCH 14/17] Update internal/app/app.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/app/app.go | 36 +++++------------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index df92097..fac8dc3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -865,38 +865,12 @@ func (m Model) loadProjects() tea.Cmd { return loadProjects(m.config) } -// loadProjectsAndRefreshGroup loads projects and refreshes group view if currently in a group +// loadProjectsAndRefreshGroup loads projects and lets Update refresh the view func (m *Model) loadProjectsAndRefreshGroup() tea.Cmd { - return func() tea.Msg { - // Load projects first - scanner := project.NewScanner(m.config) - projects, err := scanner.Scan(m.config.ReposPath) - if err != nil { - return errMsg(err) - } - - // Sort projects - sortBy := project.SortBy(m.config.Display.SortBy) - projects = project.Sort(projects, sortBy) - - // Update the model's projects - m.projects = projects - - // If we're currently viewing a group, refresh the group projects - if m.selectedGroup != nil { - m.groupProjects = m.getChildProjects(m.selectedGroup.Path) - m.groupList = views.NewGroupListModel(m.groupProjects) - m.updateSizes() - } - - // Update the main project list too - if len(m.projects) > 0 { - m.projectList = views.NewProjectListModel(m.projects) - m.updateSizes() - } - - return projectsLoadedMsg(projects) - } + // 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 From 27754b7cbe11d61ce70b6ddad7d208abef704401 Mon Sep 17 00:00:00 2001 From: Charlie Date: Thu, 22 Jan 2026 12:30:01 +0000 Subject: [PATCH 15/17] Update internal/project/scanner.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/project/scanner.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/internal/project/scanner.go b/internal/project/scanner.go index a3cb7b4..a4d2e5d 100644 --- a/internal/project/scanner.go +++ b/internal/project/scanner.go @@ -317,19 +317,16 @@ func Sort(projects []*Project, by SortBy) []*Project { case SortByLastModified: return pi.LastModified.After(pj.LastModified) case SortByLanguage: - // Groups without language sort after projects with language - langI := pi.Language - langJ := pj.Language - if pi.IsGroup { - langI = "zzz" // Sort groups to end when sorting by language + // 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 pj.IsGroup { - langJ = "zzz" - } - if langI == langJ { + if pi.Language == pj.Language { return pi.Name < pj.Name } - return langI < langJ + return pi.Language < pj.Language default: return pi.Name < pj.Name } From 44e2e6aeefd4253155dd879d82837abf32926171 Mon Sep 17 00:00:00 2001 From: Charlie Date: Thu, 22 Jan 2026 12:30:36 +0000 Subject: [PATCH 16/17] Update scripts/shells/zsh.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/shells/zsh.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/shells/zsh.sh b/scripts/shells/zsh.sh index 94b5990..5bcc069 100644 --- a/scripts/shells/zsh.sh +++ b/scripts/shells/zsh.sh @@ -12,10 +12,10 @@ setup_proj_zsh_integration() { # Run proj with all arguments command proj "$@" - # Check if a .proj_last_dir file exists (created by proj on directory change) + # Read .proj_last_dir (created by proj on directory change), if it exists local proj_dir_file="$HOME/.config/proj/.proj_last_dir" - if [[ -f "$proj_dir_file" ]]; then - local target_dir="$(cat "$proj_dir_file")" + 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" From 1091cd0d7c2264eae4c8033fe2ca4c13a4d28716 Mon Sep 17 00:00:00 2001 From: Charlie Date: Thu, 22 Jan 2026 12:30:45 +0000 Subject: [PATCH 17/17] Update docs/INSTALL.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index cb82181..dc10e4e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -353,7 +353,7 @@ source ~/.bashrc #### Zsh ```bash -# Download the integration script +# 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