refactor to decouple commands form entrypoint for reusability and centralizing commands.
Increase reusability and composition
This commit is contained in:
36
pkg/repl/completer.go
Normal file
36
pkg/repl/completer.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package repl
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/c-bata/go-prompt"
|
||||
)
|
||||
|
||||
// completer provides suggestions for the REPL input based on the current input text.
|
||||
func completer(d prompt.Document) []prompt.Suggest {
|
||||
text := d.TextBeforeCursor() // Gets the text before the cursor as a string.
|
||||
words := strings.Fields(text) // Split the string into words as []string.
|
||||
|
||||
// If there are no words, return no suggestions.
|
||||
if len(words) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the first word is not "pm", only provide root-level suggestions.
|
||||
if words[0] != "pm" {
|
||||
return filterByPrefix(rootSuggestions, words[0])
|
||||
}
|
||||
|
||||
// If the first word is "pm", provide command and flag suggestions based on the context.
|
||||
pmWords := words[1:]
|
||||
if len(words) == 1 || (len(pmWords) == 1 && !strings.HasSuffix(text, " ")) {
|
||||
return commandSuggestions(pmWords)
|
||||
}
|
||||
|
||||
// If the last word starts with a "-", provide flag suggestions for the current command.
|
||||
if len(pmWords) >= 1 {
|
||||
return flagSuggestions(pmWords[0], pmWords, text)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
49
pkg/repl/executor.go
Normal file
49
pkg/repl/executor.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package repl
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/LazyBachelor/LazyPM/internal/commands/issues"
|
||||
)
|
||||
|
||||
// execute processes the input command and returns the output
|
||||
func execute(input string) (string, error) {
|
||||
if input == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if input == "help" {
|
||||
return ReplHelp, nil
|
||||
}
|
||||
|
||||
if input == "title" {
|
||||
return ReplTitle, nil
|
||||
}
|
||||
|
||||
if after, ok := strings.CutPrefix(input, "pm"); ok {
|
||||
return executePMCommand(after)
|
||||
}
|
||||
return executeShellCommand(input)
|
||||
}
|
||||
|
||||
func executeShellCommand(input string) (string, error) {
|
||||
parts := strings.Fields(input)
|
||||
if len(parts) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(parts[0], parts[1:]...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
func executePMCommand(input string) (string, error) {
|
||||
parts := strings.Fields(input)
|
||||
if len(parts) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
output, err := issuesCmd.ExecuteArgsString(parts)
|
||||
return output, err
|
||||
}
|
||||
22
pkg/repl/options.go
Normal file
22
pkg/repl/options.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package repl
|
||||
|
||||
import "github.com/c-bata/go-prompt"
|
||||
|
||||
const PromptPrefix = "> "
|
||||
const OptionMaxSuggestions = 5
|
||||
|
||||
// promptOptions returns a slice of prompt.Option
|
||||
// to configure the behavior and appearance of the REPL prompt.
|
||||
func promptOptions(history []string) []prompt.Option {
|
||||
return []prompt.Option{
|
||||
prompt.OptionPrefixTextColor(prompt.Cyan),
|
||||
prompt.OptionMaxSuggestion(OptionMaxSuggestions),
|
||||
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),
|
||||
}
|
||||
}
|
||||
154
pkg/repl/repl.go
Normal file
154
pkg/repl/repl.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Package repl implements the Read-Eval-Print Loop (REPL) for the PM CLI.
|
||||
package repl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/LazyBachelor/LazyPM/internal/commands/issues"
|
||||
surveyCmd "github.com/LazyBachelor/LazyPM/internal/commands/survey"
|
||||
"github.com/LazyBachelor/LazyPM/internal/service"
|
||||
"github.com/LazyBachelor/LazyPM/internal/style"
|
||||
"github.com/LazyBachelor/LazyPM/pkg/cli"
|
||||
"github.com/LazyBachelor/LazyPM/pkg/task"
|
||||
"github.com/LazyBachelor/LazyPM/pkg/tui/styles"
|
||||
"github.com/c-bata/go-prompt"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
ReplHelp = `Type 'pm help' for available PM commands.
|
||||
Type 'pm status' to check task progress.
|
||||
You can also run shell commands directly. Type 'exit' or 'quit' to leave.`
|
||||
|
||||
ReplTitle = "Welcome to Project Management CLI! " + ReplHelp
|
||||
)
|
||||
|
||||
type REPL struct {
|
||||
feedbackChan chan task.ValidationFeedback
|
||||
quitChan chan bool
|
||||
app *service.App
|
||||
|
||||
currentFeedback task.ValidationFeedback
|
||||
exitRequested bool
|
||||
}
|
||||
|
||||
func NewRepl() *REPL {
|
||||
return &REPL{}
|
||||
}
|
||||
|
||||
// Run starts the interactive Read-Eval-Print Loop for the PM CLI.
|
||||
func (r *REPL) Run(ctx context.Context, config cli.Config) error {
|
||||
// Set terminal to raw mode to capture input properly in the REPL.
|
||||
// This allows us to handle input character by character and provide a better user experience.
|
||||
// We also ensure that the terminal state is restored when the REPL exits, even if an error occurs.
|
||||
oldState, err := term.GetState(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get terminal state: %w", err)
|
||||
}
|
||||
defer term.Restore(int(os.Stdin.Fd()), oldState)
|
||||
|
||||
// Initialize services for beads, config and stats.
|
||||
app, cleanup, err := service.NewServices(ctx, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize services: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// Make sure to set app, to ensure they are available.
|
||||
issuesCmd.SetApp(app)
|
||||
|
||||
// Store app reference for updating feedback
|
||||
r.app = app
|
||||
|
||||
fmt.Println(style.TitleStyle.Render(ReplTitle)) // Print REPL title.
|
||||
|
||||
// Start goroutine to watch for validation feedback and quit signals
|
||||
if r.feedbackChan != nil && r.quitChan != nil {
|
||||
go r.watchValidation()
|
||||
}
|
||||
|
||||
// history keeps track of command history.
|
||||
// This enables navigating through previous commands.
|
||||
var history []string
|
||||
|
||||
// Start the REPL loop, which continues until the user types "exit" or "quit" or task completes.
|
||||
for !r.exitRequested {
|
||||
// Check if we should exit before prompting (non-blocking check)
|
||||
if r.exitRequested {
|
||||
break
|
||||
}
|
||||
|
||||
// Prompt the user for input, and provide suggestions.
|
||||
input := prompt.Input(
|
||||
PromptPrefix,
|
||||
completer,
|
||||
promptOptions(history)...,
|
||||
)
|
||||
|
||||
// Check again after prompt returns (in case validation completed while waiting)
|
||||
if r.exitRequested {
|
||||
break
|
||||
}
|
||||
|
||||
// Trim whitespace from the input to ensure consistent command processing.
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
// If the user types "exit" or "quit", break the loop and exit the REPL.
|
||||
if input == "exit" || input == "quit" {
|
||||
fmt.Println("Goodbye!")
|
||||
break
|
||||
}
|
||||
|
||||
// Add the input to the history for future navigation.
|
||||
history = append(history, input)
|
||||
|
||||
output, _ := execute(input) // Ignore errors for now, gives better ux
|
||||
fmt.Println(style.TextStyle.Render(output)) // Print the output of the command in a styled format.
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *REPL) watchValidation() {
|
||||
for {
|
||||
select {
|
||||
case feedback := <-r.feedbackChan:
|
||||
r.currentFeedback = feedback
|
||||
// Update app's CurrentFeedback so status command can access it
|
||||
if r.app != nil {
|
||||
r.app.CurrentFeedback = &service.ValidationFeedback{
|
||||
Success: feedback.Success,
|
||||
Message: feedback.Message,
|
||||
}
|
||||
}
|
||||
if feedback.Success {
|
||||
fmt.Printf("\n%s\n", styles.TitleStyle.Render("Task completed successfully!"))
|
||||
fmt.Println("Press Enter to exit...")
|
||||
r.exitRequested = true
|
||||
return
|
||||
}
|
||||
case <-r.quitChan:
|
||||
r.exitRequested = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetChannels sets the channels for receiving validation feedback and quit signals from the task interface
|
||||
func (r *REPL) SetChannels(feedbackChan chan task.ValidationFeedback, quitChan chan bool) {
|
||||
r.feedbackChan = feedbackChan
|
||||
r.quitChan = quitChan
|
||||
}
|
||||
|
||||
func init() {
|
||||
issuesCmd.RootCmd.AddCommand(issuesCmd.GetCmd)
|
||||
issuesCmd.RootCmd.AddCommand(issuesCmd.ListCmd)
|
||||
issuesCmd.RootCmd.AddCommand(issuesCmd.CloseCmd)
|
||||
issuesCmd.RootCmd.AddCommand(issuesCmd.CreateCmd)
|
||||
issuesCmd.RootCmd.AddCommand(issuesCmd.DeleteCmd)
|
||||
issuesCmd.RootCmd.AddCommand(issuesCmd.UpdateCmd)
|
||||
issuesCmd.RootCmd.AddCommand(surveyCmd.StatusCmd)
|
||||
}
|
||||
202
pkg/repl/suggestions.go
Normal file
202
pkg/repl/suggestions.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package repl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/LazyBachelor/LazyPM/internal/commands/issues"
|
||||
"github.com/c-bata/go-prompt"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
)
|
||||
|
||||
// rootSuggestions is a list of prompt suggestions for root-level commands.
|
||||
var rootSuggestions = []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"},
|
||||
}
|
||||
|
||||
// commandSuggestions is a list of prompt suggestions for PM commands.
|
||||
var baseSuggestions = []prompt.Suggest{
|
||||
{Text: "help", Description: "Show help information"},
|
||||
{Text: "status", Description: "Show task status"},
|
||||
{Text: "delete", Description: "Delete an issue by ID"},
|
||||
{Text: "close", Description: "Close an issue by ID"},
|
||||
{Text: "create", Description: "Create a new issue with title"},
|
||||
{Text: "update", Description: "Update an existing issue by ID"},
|
||||
{Text: "describe", Description: "Get issue details by ID"},
|
||||
{Text: "list", Description: "List all issues"},
|
||||
}
|
||||
|
||||
// createFlags is a list of prompt suggestions for the create command flags.
|
||||
var createFlags = []prompt.Suggest{
|
||||
{Text: "--interactive", Description: "Create issue interactively"},
|
||||
{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)"},
|
||||
}
|
||||
|
||||
// updateFlags is a list of prompt suggestions for the update command flags.
|
||||
var updateFlags = []prompt.Suggest{
|
||||
{Text: "--title", Description: "New issue title"},
|
||||
{Text: "--desc", Description: "New issue description"},
|
||||
{Text: "--status", Description: "New issue status (open, closed, in_progress)"},
|
||||
{Text: "--type", Description: "New issue type (bug, feature, task)"},
|
||||
{Text: "--priority", Description: "New issue priority (0-5)"},
|
||||
}
|
||||
|
||||
// listFlags is a list of prompt suggestions for the list command flags.
|
||||
var listFlags = []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"},
|
||||
}
|
||||
|
||||
var deleteFlags = []prompt.Suggest{
|
||||
{Text: "--yes", Description: "Confirm deletion without prompt"},
|
||||
{Text: "--interactive", Description: "Select issues to delete interactively"},
|
||||
}
|
||||
|
||||
// statusValues is a list of prompt suggestions for status types
|
||||
var statusValues = []prompt.Suggest{
|
||||
{Text: "open", Description: "Open status"},
|
||||
{Text: "closed", Description: "Closed status"},
|
||||
{Text: "in_progress", Description: "In progress status"},
|
||||
}
|
||||
|
||||
// typeValues is a list of prompt suggestions for issue types
|
||||
var typeValues = []prompt.Suggest{
|
||||
{Text: "bug", Description: "Bug issue type"},
|
||||
{Text: "feature", Description: "Feature issue type"},
|
||||
{Text: "task", Description: "Task issue type"},
|
||||
}
|
||||
|
||||
// priorityValues is a list of prompt suggestions for issue priority levels
|
||||
var priorityValues = []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"},
|
||||
}
|
||||
|
||||
// isIDCommand maps command names to a boolean indicating whether they expect an issue ID as an argument.
|
||||
var isIDCommand = map[string]bool{
|
||||
"describe": true,
|
||||
"delete": true,
|
||||
"del": true,
|
||||
"rm": true,
|
||||
"remove": true,
|
||||
"get": true,
|
||||
"read": true,
|
||||
"close": true,
|
||||
"update": true,
|
||||
"edit": true,
|
||||
}
|
||||
|
||||
var commandFlags = map[string][]prompt.Suggest{
|
||||
"create": createFlags,
|
||||
"add": createFlags,
|
||||
"update": updateFlags,
|
||||
"edit": updateFlags,
|
||||
"list": listFlags,
|
||||
"ls": listFlags,
|
||||
"search": listFlags,
|
||||
"delete": deleteFlags,
|
||||
"del": deleteFlags,
|
||||
"rm": deleteFlags,
|
||||
"remove": deleteFlags,
|
||||
}
|
||||
|
||||
// commandSuggestions returns a list of prompt suggestions based on the current input words.
|
||||
func commandSuggestions(words []string) []prompt.Suggest {
|
||||
if len(words) == 0 {
|
||||
return baseSuggestions
|
||||
}
|
||||
return filterByPrefix(baseSuggestions, words[0])
|
||||
}
|
||||
|
||||
// flagSuggestions returns a list of prompt suggestions for command flags based on the current input.
|
||||
func flagSuggestions(cmd string, words []string, text string) []prompt.Suggest {
|
||||
lastWord, prevWord := parseWords(words, text)
|
||||
|
||||
if values := getFlagValues(prevWord); values != nil {
|
||||
return filterByPrefix(values, lastWord)
|
||||
}
|
||||
|
||||
flags := commandFlags[cmd]
|
||||
|
||||
if isIDCommand[cmd] {
|
||||
if len(words) < 2 && !strings.HasPrefix(lastWord, "-") {
|
||||
return issueIDSuggestions(lastWord, true)
|
||||
}
|
||||
return filterByPrefix(flags, lastWord)
|
||||
}
|
||||
|
||||
return filterByPrefix(flags, lastWord)
|
||||
}
|
||||
|
||||
// issueIDSuggestions returns a list of prompt suggestions for issue IDs based on the current partial input.
|
||||
func issueIDSuggestions(partial string, hasCommand bool) []prompt.Suggest {
|
||||
// Only show suggestions if we've typed the command already
|
||||
if !hasCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
issues, _ := issuesCmd.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
|
||||
}
|
||||
|
||||
// parseWords extracts the last and previous words from the input for flag suggestion logic.
|
||||
func parseWords(words []string, text string) (lastWord, prevWord string) {
|
||||
if len(words) > 0 && !strings.HasSuffix(text, " ") {
|
||||
lastWord = words[len(words)-1]
|
||||
if len(words) >= 2 {
|
||||
prevWord = words[len(words)-2]
|
||||
}
|
||||
} else if len(words) >= 1 {
|
||||
prevWord = words[len(words)-1]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// getFlagValues returns a list of prompt suggestions for flag values based on the given flag.
|
||||
func getFlagValues(flag string) []prompt.Suggest {
|
||||
switch flag {
|
||||
case "-s", "--status":
|
||||
return statusValues
|
||||
case "-t", "--type":
|
||||
return typeValues
|
||||
case "-p", "--priority":
|
||||
return priorityValues
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterByPrefix filters a list of prompt suggestions based on a given prefix.
|
||||
func filterByPrefix(suggestions []prompt.Suggest, prefix string) []prompt.Suggest {
|
||||
if prefix == "" {
|
||||
return suggestions
|
||||
}
|
||||
var filtered []prompt.Suggest
|
||||
for _, s := range suggestions {
|
||||
if strings.HasPrefix(s.Text, prefix) {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
Reference in New Issue
Block a user