diff --git a/README.md b/README.md index d269efd..a8b44e7 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ git-scope -h ## ✨ Features + * **πŸ“ Workspace Switch** β€” Switch root directories without quitting (`w`). Supports `~`, relative paths, and **symlinks**. * **πŸ” Fuzzy Search** β€” Find any repo by name, path, or branch (`/`). * **πŸ›‘οΈ Dirty Filter** β€” Instantly show only repos with uncommitted changes (`f`). * **πŸš€ Editor Jump** β€” Open the selected repo in VSCode, Neovim, Vim, or Helix (`Enter`). @@ -81,6 +82,7 @@ git-scope -h * **🌿 Contribution Graph** β€” GitHub-style local heatmap for your activity (`g`). * **πŸ’Ύ Disk Usage** β€” Visualize `.git` vs `node_modules` size (`d`). * **⏰ Timeline** β€” View recent activity across all projects (`t`). + * **πŸ”— Symlink Support** β€” Symlinked directories resolve transparently (great for Codespaces/devcontainers). ----- @@ -88,6 +90,7 @@ git-scope -h | Key | Action | | :--- | :--- | +| `w` | **Switch Workspace** (with Tab completion) | | `/` | **Search** repositories (Fuzzy) | | `f` | **Filter** (Cycle: All / Dirty / Clean) | | `s` | Cycle **Sort** Mode | @@ -142,6 +145,8 @@ I built `git-scope` to solve the **"Multi-Repo Blindness"** problem. It gives me ## πŸ—ΊοΈ Roadmap + - [x] In-app workspace switching with Tab completion + - [x] Symlink resolution for devcontainers/Codespaces - [ ] Background file watcher (real-time updates) - [ ] Quick actions (bulk pull/fetch) - [ ] Repo grouping (Service / Team / Stack) diff --git a/internal/nudge/nudge.go b/internal/nudge/nudge.go new file mode 100644 index 0000000..926b7fc --- /dev/null +++ b/internal/nudge/nudge.go @@ -0,0 +1,115 @@ +package nudge + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// Version is the current app version - used to track per-version nudge +const Version = "1.3.0" + +// GitHubRepoURL is the URL to open when user presses S +const GitHubRepoURL = "https://github.com/Bharath-code/git-scope" + +// NudgeState represents the persistent state of the star nudge +type NudgeState struct { + SeenVersion string `json:"seenVersion"` + Dismissed bool `json:"dismissed"` + Completed bool `json:"completed"` +} + +// getNudgePath returns the path to the nudge state file +func getNudgePath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".cache", "git-scope", "nudge.json") +} + +// loadState loads the nudge state from disk +func loadState() *NudgeState { + path := getNudgePath() + if path == "" { + return &NudgeState{} + } + + data, err := os.ReadFile(path) + if err != nil { + return &NudgeState{} + } + + var state NudgeState + if err := json.Unmarshal(data, &state); err != nil { + return &NudgeState{} + } + + return &state +} + +// saveState saves the nudge state to disk +func saveState(state *NudgeState) error { + path := getNudgePath() + if path == "" { + return nil + } + + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +// ShouldShowNudge checks if the star nudge should be shown +// Returns true only if: +// - Not already seen for this version +// - Not dismissed +// - Not completed (user already starred) +func ShouldShowNudge() bool { + state := loadState() + + // Already seen for this version + if state.SeenVersion == Version { + return false + } + + // User already completed (pressed S) + if state.Completed { + return false + } + + return true +} + +// MarkShown marks the nudge as shown for the current version +func MarkShown() { + state := loadState() + state.SeenVersion = Version + state.Dismissed = false + _ = saveState(state) +} + +// MarkDismissed marks the nudge as dismissed (any key pressed) +func MarkDismissed() { + state := loadState() + state.SeenVersion = Version + state.Dismissed = true + _ = saveState(state) +} + +// MarkCompleted marks the nudge as completed (S pressed, GitHub opened) +func MarkCompleted() { + state := loadState() + state.SeenVersion = Version + state.Completed = true + _ = saveState(state) +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 5b75d13..f2bad70 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -23,6 +23,7 @@ const ( StateReady StateError StateSearching + StateWorkspaceSwitch ) // SortMode represents different sorting options @@ -66,6 +67,13 @@ type Model struct { grassData *stats.ContributionData diskData *stats.DiskUsageData timelineData *stats.TimelineData + // Workspace switch state + workspaceInput textinput.Model + workspaceError string + activeWorkspace string + // Star nudge state + showStarNudge bool + nudgeShownThisSession bool } // NewModel creates a new TUI model @@ -115,19 +123,26 @@ func NewModel(cfg *config.Config) Model { ti.CharLimit = 50 ti.Width = 30 + // Create text input for workspace switch + wi := textinput.New() + wi.Placeholder = "~/projects or /path/to/dir" + wi.CharLimit = 200 + wi.Width = 40 + // Create spinner with Braille pattern sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#7C3AED")) return Model{ - cfg: cfg, - table: t, - textInput: ti, - spinner: sp, - state: StateLoading, - sortMode: SortByDirty, - filterMode: FilterAll, + cfg: cfg, + table: t, + textInput: ti, + workspaceInput: wi, + spinner: sp, + state: StateLoading, + sortMode: SortByDirty, + filterMode: FilterAll, } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 4355716..e2148db 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -3,9 +3,13 @@ package tui import ( "fmt" "os/exec" + "runtime" "github.com/Bharath-code/git-scope/internal/model" + "github.com/Bharath-code/git-scope/internal/nudge" + "github.com/Bharath-code/git-scope/internal/scan" "github.com/Bharath-code/git-scope/internal/stats" + "github.com/Bharath-code/git-scope/internal/workspace" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -50,6 +54,31 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err return m, nil + case workspaceScanCompleteMsg: + m.repos = msg.repos + m.state = StateReady + m.updateTable() + + // Show helpful message about switched workspace + if len(msg.repos) == 0 { + m.statusMsg = fmt.Sprintf("⚠️ No git repos found in %s", msg.workspacePath) + } else { + m.statusMsg = fmt.Sprintf("βœ“ Switched to %s (%d repos)", msg.workspacePath, len(msg.repos)) + + // Trigger star nudge after successful workspace switch + if nudge.ShouldShowNudge() && !m.nudgeShownThisSession { + m.showStarNudge = true + m.nudgeShownThisSession = true + nudge.MarkShown() + } + } + return m, nil + + case workspaceScanErrorMsg: + m.state = StateError + m.err = msg.err + return m, nil + case openEditorMsg: // Parse editor command (handles "editor --flag" style configs) fields, err := shell.Fields(m.cfg.Editor, nil) @@ -108,11 +137,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleSearchMode(msg) } + // Handle workspace switch mode + if m.state == StateWorkspaceSwitch { + return m.handleWorkspaceSwitchMode(msg) + } + // Normal mode key handling switch msg.String() { case "ctrl+c", "q": return m, tea.Quit + case "S": + // Open GitHub repo (Star nudge action) + if m.showStarNudge { + m.showStarNudge = false + nudge.MarkCompleted() + m.statusMsg = "⭐ Opening GitHub..." + return m, openBrowserCmd(nudge.GitHubRepoURL) + } + case "/": // Enter search mode if m.state == StateReady { @@ -263,7 +306,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "" return m, nil } - } + + case "w": + // Open workspace switch modal + if m.state == StateReady { + m.state = StateWorkspaceSwitch + m.workspaceInput.SetValue("") + m.workspaceInput.Focus() + m.workspaceError = "" + return m, textinput.Blink + } + } + } + + // Dismiss star nudge on any key (if not already handled) + if m.showStarNudge { + m.showStarNudge = false + nudge.MarkDismissed() } // Update the table @@ -354,3 +413,111 @@ func loadTimelineDataCmd(repos []model.Repo) tea.Cmd { return timelineDataLoadedMsg{data: data} } } + +// handleWorkspaceSwitchMode handles key events when in workspace switch mode +func (m Model) handleWorkspaceSwitchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + // Cancel workspace switch + m.state = StateReady + m.workspaceInput.Blur() + m.workspaceError = "" + return m, nil + + case "enter": + // Validate and switch workspace + inputPath := m.workspaceInput.Value() + if inputPath == "" { + m.workspaceError = "Please enter a path" + return m, nil + } + + // Normalize the path (expand ~, resolve symlinks, validate) + normalizedPath, err := workspace.NormalizeWorkspacePath(inputPath) + if err != nil { + m.workspaceError = err.Error() + return m, nil + } + + // Switch to loading state and scan the new workspace + m.state = StateLoading + m.workspaceInput.Blur() + m.workspaceError = "" + m.activeWorkspace = normalizedPath + m.statusMsg = "πŸ”„ Switching to " + normalizedPath + "..." + + return m, scanWorkspaceCmd(normalizedPath, m.cfg.Ignore) + + case "tab": + // Tab completion for directory paths + currentPath := m.workspaceInput.Value() + if currentPath != "" { + completedPath := workspace.CompleteDirectoryPath(currentPath) + if completedPath != currentPath { + m.workspaceInput.SetValue(completedPath) + // Move cursor to end + m.workspaceInput.CursorEnd() + } + } + return m, nil + + case "ctrl+c": + return m, tea.Quit + } + + // Update text input + var cmd tea.Cmd + m.workspaceInput, cmd = m.workspaceInput.Update(msg) + + // Clear error when typing + if m.workspaceError != "" { + m.workspaceError = "" + } + + return m, cmd +} + +// workspaceScanCompleteMsg is sent when workspace scanning is complete +type workspaceScanCompleteMsg struct { + repos []model.Repo + workspacePath string +} + +// workspaceScanErrorMsg is sent when workspace scanning fails +type workspaceScanErrorMsg struct { + err error +} + +// scanWorkspaceCmd scans a single workspace path for repositories +func scanWorkspaceCmd(workspacePath string, ignore []string) tea.Cmd { + return func() tea.Msg { + repos, err := scan.ScanRoots([]string{workspacePath}, ignore) + if err != nil { + return workspaceScanErrorMsg{err: err} + } + + return workspaceScanCompleteMsg{ + repos: repos, + workspacePath: workspacePath, + } + } +} + +// openBrowserCmd opens a URL in the default browser +func openBrowserCmd(url string) tea.Cmd { + return func() tea.Msg { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return nil + } + _ = cmd.Run() + return nil + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 96be204..80bdbcd 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -23,6 +23,8 @@ func (m Model) renderContent() string { b.WriteString(m.renderError()) case StateReady, StateSearching: b.WriteString(m.renderDashboard()) + case StateWorkspaceSwitch: + b.WriteString(m.renderWorkspaceModal()) } return b.String() @@ -40,10 +42,18 @@ func (m Model) renderLoading() string { b.WriteString(subtitleStyle.Render("Searching for git repos in:")) b.WriteString("\n") - for _, root := range m.cfg.Roots { + + // Show workspace path if switching, otherwise show config roots + if m.activeWorkspace != "" { b.WriteString(pathBulletStyle.Render(" β†’ ")) - b.WriteString(pathStyle.Render(root)) + b.WriteString(pathStyle.Render(m.activeWorkspace)) b.WriteString("\n") + } else { + for _, root := range m.cfg.Roots { + b.WriteString(pathBulletStyle.Render(" β†’ ")) + b.WriteString(pathStyle.Render(root)) + b.WriteString("\n") + } } b.WriteString("\n") @@ -94,7 +104,7 @@ func (m Model) renderDashboard() string { // Header with logo on its own line logo := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#A78BFA")).Render("git-scope") - version := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render(" v1.2.2") + version := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render(" v1.3.0") b.WriteString(logo + version) b.WriteString("\n\n") @@ -143,6 +153,12 @@ func (m Model) renderDashboard() string { b.WriteString("\n") } + // Star nudge (if active) + if m.showStarNudge { + b.WriteString(m.renderStarNudge()) + b.WriteString("\n") + } + // Legend b.WriteString(m.renderLegend()) b.WriteString("\n") @@ -261,6 +277,14 @@ func (m Model) renderHelp() string { keyBinding("enter", "apply"), keyBinding("esc", "cancel"), } + } else if m.state == StateWorkspaceSwitch { + // Workspace switch mode help + items = []string{ + keyBinding("type", "path"), + keyBinding("tab", "complete"), + keyBinding("enter", "switch"), + keyBinding("esc", "cancel"), + } } else if m.activePanel != PanelNone { // Panel active help items = []string{ @@ -277,6 +301,7 @@ func (m Model) renderHelp() string { keyBinding("↑↓", "nav"), keyBinding("enter", "open"), keyBinding("/", "search"), + keyBinding("w", "workspace"), keyBinding("f", "filter"), keyBinding("s", "sort"), keyBinding("g", "grass"), @@ -294,3 +319,69 @@ func (m Model) renderHelp() string { func keyBinding(key, action string) string { return keyBindingKeyStyle.Render(key) + " " + action } + +// renderWorkspaceModal renders the workspace switch modal +func (m Model) renderWorkspaceModal() string { + var b strings.Builder + + // Header with logo + b.WriteString(compactLogo()) + b.WriteString("\n\n") + + // Modal box + modalStyle := lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7C3AED")). + Padding(1, 2). + Width(50) + + // Modal title + title := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A78BFA")). + Bold(true). + Render("πŸ“ Switch Workspace") + + // Path input + label := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C3AED")). + Bold(true). + Render("Path: ") + + // Error message if any + errorLine := "" + if m.workspaceError != "" { + errorLine = "\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#EF4444")). + Render("❌ " + m.workspaceError) + } + + // Footer hints + footer := lipgloss.NewStyle(). + Foreground(mutedColor). + Render("\n\nTab = complete Enter = scan Esc = cancel") + + modalContent := title + "\n\n" + label + m.workspaceInput.View() + errorLine + footer + b.WriteString(modalStyle.Render(modalContent)) + + // Help bar + b.WriteString("\n\n") + b.WriteString(m.renderHelp()) + + return b.String() +} + +// renderStarNudge renders the subtle star nudge message in the footer +func (m Model) renderStarNudge() string { + nudgeStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FCD34D")). + Italic(true) + + ctaStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A78BFA")). + Bold(true) + + message := nudgeStyle.Render("✨ If git-scope helped you stay in flow, a GitHub star helps others discover it.") + cta := ctaStyle.Render(" (S) Open GitHub") + + return message + cta +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go new file mode 100644 index 0000000..77d9af3 --- /dev/null +++ b/internal/workspace/workspace.go @@ -0,0 +1,189 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// NormalizeWorkspacePath normalizes a workspace path input from the user. +// It expands ~, converts relative paths to absolute, resolves symlinks, +// and validates that the path exists and is a directory. +func NormalizeWorkspacePath(input string) (string, error) { + if input == "" { + return "", fmt.Errorf("path cannot be empty") + } + + path := input + + // Step 1: Expand ~ to home directory + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot expand ~: %w", err) + } + path = filepath.Join(home, path[2:]) + } else if path == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot expand ~: %w", err) + } + path = home + } + + // Step 2: Convert relative paths to absolute + if !filepath.IsAbs(path) { + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("cannot resolve path: %w", err) + } + path = absPath + } + + // Step 3: Check if path exists before resolving symlinks + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("path does not exist: %s", input) + } + return "", fmt.Errorf("cannot access path: %w", err) + } + + // Step 4: Validate it's a directory + if !info.IsDir() { + return "", fmt.Errorf("path is not a directory: %s", input) + } + + // Step 5: Resolve symlinks + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + // If symlink resolution fails, use the original path + // (might happen with broken symlinks) + return path, nil + } + + return resolved, nil +} + +// expandTilde expands ~ to home directory without validation +func expandTilde(path string) string { + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return path + } + return filepath.Join(home, path[2:]) + } else if path == "~" { + home, err := os.UserHomeDir() + if err != nil { + return path + } + return home + } + return path +} + +// CompleteDirectoryPath attempts to complete a partial directory path. +// It returns the completed path if a unique match is found, or the longest +// common prefix if multiple matches exist. Returns the original input if +// no matches are found. +func CompleteDirectoryPath(input string) string { + if input == "" { + return input + } + + // Remember if input started with ~ + hadTilde := strings.HasPrefix(input, "~") + + // Expand tilde for processing + path := expandTilde(input) + + // Handle relative paths + if !filepath.IsAbs(path) { + absPath, err := filepath.Abs(path) + if err != nil { + return input + } + path = absPath + } + + // Get the directory and prefix to match + dir := filepath.Dir(path) + prefix := filepath.Base(path) + + // If the path exists as-is and is a directory, just add trailing slash + info, err := os.Stat(path) + if err == nil && info.IsDir() { + // Path is already a complete directory + if hadTilde { + home, _ := os.UserHomeDir() + if strings.HasPrefix(path, home) { + return "~" + strings.TrimPrefix(path, home) + "/" + } + } + if !strings.HasSuffix(path, "/") { + return path + "/" + } + return path + } + + // Read the parent directory to find matches + entries, err := os.ReadDir(dir) + if err != nil { + return input + } + + // Find directories that start with the prefix + var matches []string + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) { + matches = append(matches, entry.Name()) + } + } + + if len(matches) == 0 { + return input + } + + if len(matches) == 1 { + // Unique match - complete it + completedPath := filepath.Join(dir, matches[0]) + + // Convert back to ~ format if it started with ~ + if hadTilde { + home, _ := os.UserHomeDir() + if strings.HasPrefix(completedPath, home) { + return "~" + strings.TrimPrefix(completedPath, home) + "/" + } + } + return completedPath + "/" + } + + // Multiple matches - find longest common prefix + commonPrefix := matches[0] + for _, match := range matches[1:] { + for i := 0; i < len(commonPrefix) && i < len(match); i++ { + if commonPrefix[i] != match[i] { + commonPrefix = commonPrefix[:i] + break + } + } + if len(match) < len(commonPrefix) { + commonPrefix = match + } + } + + if len(commonPrefix) > len(prefix) { + completedPath := filepath.Join(dir, commonPrefix) + if hadTilde { + home, _ := os.UserHomeDir() + if strings.HasPrefix(completedPath, home) { + return "~" + strings.TrimPrefix(completedPath, home) + } + } + return completedPath + } + + return input +}