diff --git a/pkg/cli/repl/repl.go b/pkg/cli/repl/repl.go new file mode 100644 index 0000000..796f4fb --- /dev/null +++ b/pkg/cli/repl/repl.go @@ -0,0 +1,290 @@ +package repl + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/LazyBachelor/LazyPM/internal/service" + "github.com/LazyBachelor/LazyPM/pkg/cli" + "github.com/LazyBachelor/LazyPM/pkg/cli/commands" + "github.com/LazyBachelor/LazyPM/pkg/cli/styles" + "github.com/c-bata/go-prompt" + "github.com/muesli/reflow/truncate" + "golang.org/x/term" +) + +const ( + ReplHelp = `Type 'pm help' for available PM commands. +You can also run shell commands directly. Type 'exit' or 'quit' to leave.` + + ReplTitle = "Welcome to Project Management CLI! " + ReplHelp +) + +func RunREPL(ctx context.Context, config cli.CLIConfig) error { + oldState, err := term.GetState(int(os.Stdin.Fd())) + if err != nil { + return fmt.Errorf("failed to get terminal state: %w", err) + } + + svc, cleanup, err := service.NewServices(ctx, config) + if err != nil { + return fmt.Errorf("failed to initialize services: %w", err) + } + defer cleanup() + + commands.SetServices(svc) + + fmt.Println("\n" + styles.TitleStyle.Render(ReplTitle)) + + var history []string + + for { + input := prompt.Input( + "› ", + completer, + prompt.OptionPrefixTextColor(prompt.Cyan), + prompt.OptionMaxSuggestion(5), + prompt.OptionSuggestionBGColor(prompt.DefaultColor), + prompt.OptionSelectedSuggestionBGColor(prompt.DefaultColor), + prompt.OptionDescriptionBGColor(prompt.DefaultColor), + prompt.OptionSelectedDescriptionBGColor(prompt.DefaultColor), + prompt.OptionPreviewSuggestionBGColor(prompt.DefaultColor), + prompt.OptionScrollbarBGColor(prompt.DefaultColor), + prompt.OptionHistory(history), + ) + + input = strings.TrimSpace(input) + + if input == "" { + continue + } + + if input == "exit" || input == "quit" { + fmt.Println("Goodbye!") + break + } + + if input == "help" { + fmt.Println(ReplHelp) + continue + } + + if input == "title" { + fmt.Println(styles.TitleStyle.Render(ReplTitle)) + continue + } + + history = append(history, input) + + if after, ok := strings.CutPrefix(input, "pm "); ok { + pmCmd := strings.TrimSpace(after) + if pmCmd == "" { + continue + } + + args := strings.Fields(pmCmd) + + output, err := commands.ExecuteArgsString(args) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } + + if output != "" { + fmt.Println(styles.CommandStyle.Render(output)) + } + + } else { + cmd := exec.Command("sh", "-c", input) + out, err := cmd.CombinedOutput() + + if len(out) > 0 { + fmt.Println(styles.CommandStyle.Render(string(out))) + } + + if err != nil && len(out) == 0 { + fmt.Fprintf(os.Stderr, "%s", fmt.Sprintf("Error: %v", err)) + } + } + } + + if err := term.Restore(int(os.Stdin.Fd()), oldState); err != nil { + return fmt.Errorf("failed to restore terminal state: %w", err) + } + + return nil +} + +func completer(d prompt.Document) []prompt.Suggest { + text := d.TextBeforeCursor() + words := strings.Fields(text) + + if len(words) == 0 { + return nil + } + + if words[0] != "pm" { + suggestions := []prompt.Suggest{ + {Text: "pm", Description: "Project Management System"}, + {Text: "exit", Description: "Exit pm CLI"}, + {Text: "help", Description: "Show help information"}, + {Text: "title", Description: "Print the welcome title"}, + {Text: "git", Description: "Version control system"}, + } + + if len(words) == 0 { + return suggestions + } + + var filtered []prompt.Suggest + for _, s := range suggestions { + if strings.HasPrefix(s.Text, words[0]) { + filtered = append(filtered, s) + } + } + + return filtered + } + + // If first word is "pm", provide PM command completions + if words[0] == "pm" { + // Remove "pm" from words to get the actual command + if len(words) == 1 || (len(words) == 2 && !strings.HasSuffix(text, " ")) { + return commandSuggestions(words[1:]) + } + + // Get the PM subcommand + if len(words) >= 2 { + cmd := words[1] + return flagSuggestions(cmd, words[1:], text) + } + } + + return nil +} + +func commandSuggestions(words []string) []prompt.Suggest { + suggestions := []prompt.Suggest{ + {Text: "help", Description: "Show help information"}, + {Text: "delete", Description: "Delete an issue by ID"}, + {Text: "create", Description: "Create a new issue with title"}, + {Text: "describe", Description: "Get issue details by ID"}, + {Text: "list", Description: "List all issues"}, + } + + if len(words) == 0 { + return suggestions + } + + var filtered []prompt.Suggest + for _, s := range suggestions { + if strings.HasPrefix(s.Text, words[0]) { + filtered = append(filtered, s) + } + } + return filtered +} + +func flagSuggestions(cmd string, words []string, text string) []prompt.Suggest { + var flagSuggests []prompt.Suggest + isCompleteWord := strings.HasSuffix(text, " ") + lastWord := "" + if len(words) > 0 && !isCompleteWord { + lastWord = words[len(words)-1] + } + + switch cmd { + case "create", "add": + flagSuggests = []prompt.Suggest{ + {Text: "--desc", Description: "Issue description"}, + {Text: "--status", Description: "Issue status (open, closed, in_progress)"}, + {Text: "--type", Description: "Issue type (bug, feature, task)"}, + {Text: "--priority", Description: "Issue priority (0-5)"}, + } + return filterAndCompleteFlags(flagSuggests, lastWord, words) + + case "ls", "list", "search": + flagSuggests = []prompt.Suggest{ + {Text: "--title", Description: "Filter by title"}, + {Text: "--desc", Description: "Filter by description"}, + {Text: "--status", Description: "Filter by status (open, closed, in_progress)"}, + {Text: "--type", Description: "Filter by type (bug, feature, task)"}, + {Text: "--priority", Description: "Filter by priority (0-5)"}, + {Text: "--limit", Description: "Limit number of results"}, + } + return filterAndCompleteFlags(flagSuggests, lastWord, words) + + case "describe", "get", "read": + return issueIdSuggestions(words) + + case "delete": + return issueIdSuggestions(words) + } + + return nil +} + +func issueIdSuggestions(words []string) []prompt.Suggest { + if len(words) < 2 { + return nil + } + + // Get the partial ID being typed + partial := "" + if len(words) >= 2 { + partial = words[len(words)-1] + } + + issues, _ := commands.GetIssueCompletions(context.Background(), partial) + + var suggestions []prompt.Suggest + for _, issue := range issues { + suggestions = append(suggestions, prompt.Suggest{ + Text: issue.ID, Description: truncate.String(issue.Title, 20), + }) + } + return suggestions +} + +func filterAndCompleteFlags(suggestions []prompt.Suggest, lastWord string, words []string) []prompt.Suggest { + if len(words) >= 2 { + prevWord := words[len(words)-2] + switch prevWord { + case "-s", "--status": + return []prompt.Suggest{ + {Text: "open", Description: "Open status"}, + {Text: "closed", Description: "Closed status"}, + {Text: "in_progress", Description: "In progress status"}, + } + case "-t", "--type": + return []prompt.Suggest{ + {Text: "bug", Description: "Bug issue type"}, + {Text: "feature", Description: "Feature issue type"}, + {Text: "task", Description: "Task issue type"}, + } + case "-p", "--priority": + return []prompt.Suggest{ + {Text: "0", Description: "Lowest priority"}, + {Text: "1", Description: "Low priority"}, + {Text: "2", Description: "Medium-low priority"}, + {Text: "3", Description: "Medium priority"}, + {Text: "4", Description: "High priority"}, + {Text: "5", Description: "Highest priority"}, + } + } + } + + if lastWord == "" { + return suggestions + } + + var filtered []prompt.Suggest + for _, s := range suggestions { + if strings.HasPrefix(s.Text, lastWord) { + filtered = append(filtered, s) + } + } + return filtered +} diff --git a/pkg/cli/styles/styles.go b/pkg/cli/styles/styles.go new file mode 100644 index 0000000..067534b --- /dev/null +++ b/pkg/cli/styles/styles.go @@ -0,0 +1,12 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +var ( + TitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("6")). + Bold(true).Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("12")).Padding(1) + + CommandStyle = lipgloss.NewStyle().Padding(1) +)