forked from Gitlink/gitlink-cli
364 lines
8.8 KiB
Go
364 lines
8.8 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.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
|
|
if val == "/" || 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
|
|
}
|