gitlink-cli/cmd/interactive/repl.go

381 lines
9.4 KiB
Go

package interactive
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gitlink-org/gitlink-cli/shortcuts"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// replState tracks the current state of the REPL.
type replState int
const (
stateInput replState = iota // waiting for user text input
statePalette // command palette is active
stateForm // parameter form is active
stateExecuting // executing a command
stateError // showing an error
)
// execResultMsg is sent when command execution completes.
type execResultMsg struct {
group string
shortcut *common.Shortcut
args map[string]string
output string
err error
}
// replModel is the main REPL state machine.
type replModel struct {
state replState
input textinput.Model
palette paletteModel
form formModel
executor Executor
shortcuts map[string][]*common.Shortcut
descs map[string]string
lastOutput string
lastError string
spinner spinner.Model
owner string
repo string
width int
height int
quitting bool
showWelcome bool
}
var promptStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
var successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
var errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
var welcomeStyle = lipgloss.NewStyle().Faint(true)
// newReplModel creates a new REPL model.
func newReplModel(owner, repo string) replModel {
ti := textinput.New()
ti.Prompt = buildPrompt(owner, repo)
ti.Focus()
ti.Width = 60
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
return replModel{
state: stateInput,
input: ti,
executor: Executor{},
shortcuts: shortcuts.GetAllShortcuts(),
descs: shortcuts.GetDescriptions(),
spinner: sp,
owner: owner,
repo: repo,
showWelcome: true,
}
}
// buildPrompt creates the prompt string.
func buildPrompt(owner, repo string) string {
if owner != "" && repo != "" {
return fmt.Sprintf("gitlink (%s/%s)> ", owner, repo)
}
return "gitlink> "
}
// Init initializes the REPL.
func (m replModel) Init() tea.Cmd {
return textinput.Blink
}
// Update is the main message dispatcher.
func (m replModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tea.WindowSizeMsg:
wmsg := msg.(tea.WindowSizeMsg)
m.width = wmsg.Width
m.height = wmsg.Height
return m, nil
}
switch m.state {
case stateInput:
return m.updateInput(msg)
case statePalette:
return m.updatePalette(msg)
case stateForm:
return m.updateForm(msg)
case stateExecuting:
return m.updateExecuting(msg)
case stateError:
return m.updateError(msg)
}
return m, nil
}
// updateInput handles the text input state.
func (m replModel) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, isKey := msg.(tea.KeyMsg)
if isKey {
switch keyMsg.Type {
case tea.KeyRunes:
// Trigger the command palette immediately when the user types "/"
// as the first character of a fresh input — no Enter required.
if m.input.Value() == "" && len(keyMsg.Runes) > 0 && keyMsg.Runes[0] == '/' {
m.showWelcome = false
m.input.SetValue("")
m.palette = newPaletteModel(m.shortcuts, m.descs, m.width, m.height)
// Carry any extra characters typed alongside "/" as the initial query.
if len(keyMsg.Runes) > 1 {
query := string(keyMsg.Runes[1:])
m.palette.search.SetValue(query)
m.palette.filterItems(query)
}
m.state = statePalette
return m, nil
}
case tea.KeyEnter:
val := strings.TrimSpace(m.input.Value())
m.input.SetValue("")
m.showWelcome = false
if val == "" {
return m, nil
}
// Exit commands
if val == "exit" || val == "quit" {
m.quitting = true
return m, tea.Quit
}
// Palette mode (also reachable via "/query" + Enter)
if strings.HasPrefix(val, "/") {
m.palette = newPaletteModel(m.shortcuts, m.descs, m.width, m.height)
if len(val) > 1 {
query := val[1:]
m.palette.search.SetValue(query)
m.palette.filterItems(query)
}
m.state = statePalette
return m, nil
}
// Direct command: "group +cmd ..."
group, cmd, flagStr, ok := parseDirectCommand(val)
if ok {
s, found := findShortcut(m.shortcuts, group, cmd)
if !found {
m.lastError = fmt.Sprintf("Unknown command: %s +%s", group, cmd)
m.state = stateError
return m, nil
}
shortMap := buildShortMap(s)
args := parseFlagString(flagStr, shortMap)
missing := missingRequiredFlags(s, args)
if len(missing) > 0 {
// Show form with prefilled values
m.form = newFormModel(group, s, m.width, m.height, args)
m.state = stateForm
return m, nil
}
// Execute directly
m.state = stateExecuting
return m, m.executeCommand(group, s, args)
}
// Unrecognized input
m.lastError = fmt.Sprintf("Unknown input: %q\nType a command like \"issue +list\" or \"/\" to open the command palette.", val)
m.state = stateError
return m, nil
case tea.KeyCtrlD, tea.KeyCtrlC:
m.quitting = true
return m, tea.Quit
}
}
// Forward to text input
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
// executeCommand returns a tea.Cmd that runs a shortcut asynchronously.
func (m replModel) executeCommand(group string, s *common.Shortcut, args map[string]string) tea.Cmd {
return func() tea.Msg {
output, err := m.executor.Execute(s, args)
return execResultMsg{
group: group,
shortcut: s,
args: args,
output: output,
err: err,
}
}
}
// updatePalette delegates to the palette sub-component.
func (m replModel) updatePalette(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.palette, cmd = m.palette.Update(msg)
if m.palette.quitting {
m.palette.quitting = false
m.state = stateInput
return m, textinput.Blink
}
if m.palette.result != nil {
result := m.palette.result
s := result.Shortcut
// Only open the form when required flags are missing.
// Commands with only optional flags execute directly — users can pass
// flags via the direct-command syntax if needed.
missing := missingRequiredFlags(s, map[string]string{})
if len(missing) > 0 {
m.form = newFormModel(result.Group, s, m.width, m.height, nil)
m.state = stateForm
return m, nil
}
// No required flags missing → execute directly
m.state = stateExecuting
return m, tea.Batch(m.executeCommand(result.Group, s, map[string]string{}), m.spinner.Tick)
}
return m, cmd
}
// updateForm delegates to the form sub-component.
func (m replModel) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.form, cmd = m.form.Update(msg)
if m.form.cancelled {
m.form.cancelled = false
m.state = stateInput
return m, textinput.Blink
}
if m.form.submitted {
args := m.form.values
group := m.form.group
s := m.form.shortcut
m.form.submitted = false
m.state = stateExecuting
return m, m.executeCommand(group, s, args)
}
return m, cmd
}
// updateExecuting waits for the execResultMsg.
func (m replModel) updateExecuting(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case execResultMsg:
if msg.err != nil {
m.lastError = fmt.Sprintf("命令执行失败: %s", msg.err)
m.lastOutput = ""
m.state = stateError
return m, nil
}
display := formatCommandDisplay(msg.group, msg.shortcut, msg.args)
m.lastOutput = fmt.Sprintf(" ✓ 执行: %s\n\n%s", display, msg.output)
m.lastError = ""
m.state = stateInput
m.input.Focus()
return m, textinput.Blink
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
}
// updateError waits for Enter to return to input state.
func (m replModel) updateError(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, isKey := msg.(tea.KeyMsg)
if isKey {
switch keyMsg.Type {
case tea.KeyEnter, tea.KeyEsc:
m.lastError = ""
m.state = stateInput
m.input.Focus()
return m, textinput.Blink
case tea.KeyCtrlD, tea.KeyCtrlC:
m.quitting = true
return m, tea.Quit
}
}
return m, nil
}
// View renders the REPL based on current state.
func (m replModel) View() string {
if m.quitting {
return ""
}
switch m.state {
case stateInput:
var sb strings.Builder
if m.showWelcome {
sb.WriteString(welcomeStyle.Render("Welcome to gitlink-cli interactive mode."))
sb.WriteString("\n")
sb.WriteString(welcomeStyle.Render("Type / to open the command palette, \"exit\" or Ctrl+D to quit."))
sb.WriteString("\n\n")
}
if m.lastOutput != "" {
sb.WriteString(successStyle.Render(m.lastOutput))
sb.WriteString("\n")
}
sb.WriteString(m.input.View())
return sb.String()
case statePalette:
return m.palette.View()
case stateForm:
return m.form.View()
case stateExecuting:
return fmt.Sprintf("\n %s Executing command...\n", m.spinner.View())
case stateError:
var sb strings.Builder
sb.WriteString("\n")
sb.WriteString(errorStyle.Render(m.lastError))
sb.WriteString("\n\n")
sb.WriteString(formHelpStyle.Render(" Press Enter to continue"))
sb.WriteString("\n")
return sb.String()
}
return ""
}
// Run starts the interactive REPL.
func Run(owner, repo string) error {
m := newReplModel(owner, repo)
p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run()
return err
}