gitlink-cli/cmd/interactive/repl.go

434 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package interactive
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
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
stateOutput // scrolling through long command output
)
// 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
viewport viewport.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
// 保持 viewport 尺寸与终端同步(底部留 2 行给提示符/帮助行)。
m.viewport.Width = wmsg.Width
m.viewport.Height = wmsg.Height - 2
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)
case stateOutput:
return m.updateOutput(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
// Open the form whenever the command has ANY flags — not just required
// ones. Many commands have "at-least-one-of" parameters (e.g. branch
// +batch-protect needs --names OR --from) that can't be marked Required
// individually, so skipping the form for "optional-only" commands left
// users unable to supply them and the command failed at runtime.
// Commands with no flags at all execute directly.
if len(s.Flags) > 0 {
m.form = newFormModel(result.Group, s, m.width, m.height, nil)
m.state = stateForm
return m, nil
}
// No flags → 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 = formatInteractiveError(msg.group, msg.shortcut, msg.err)
m.lastOutput = ""
m.state = stateError
return m, nil
}
display := formatCommandDisplay(msg.group, msg.shortcut, msg.args)
content := fmt.Sprintf(" ✓ 执行: %s\n\n%s", display, msg.output)
// 输出超过可用高度(留 4 行给提示符与边距)时进入可滚动查看器,
// 否则保持原行为:直接显示在输入状态。
lineCount := strings.Count(content, "\n") + 1
if m.height > 0 && lineCount > m.height-4 {
m.viewport = viewport.New(m.width, m.height-2)
m.viewport.SetContent(content)
m.viewport.GotoTop()
m.lastOutput = ""
m.lastError = ""
m.state = stateOutput
return m, nil
}
m.lastOutput = content
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
}
// updateOutput 处理输出查看器状态:用方向键/PageUp/PageDown 浏览长输出,
// 按 q 或 Esc 返回输入状态。
func (m replModel) updateOutput(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc, tea.KeyCtrlC:
m.state = stateInput
m.input.Focus()
return m, textinput.Blink
case tea.KeyRunes:
// q / Q 返回输入
if len(msg.Runes) > 0 && (msg.Runes[0] == 'q' || msg.Runes[0] == 'Q') {
m.state = stateInput
m.input.Focus()
return m, textinput.Blink
}
}
}
// 其余按键方向键、PageUp/Down、Home/End转发给 viewport
// viewport 内置响应这些按键,无需手动处理。
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
// 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()
case stateOutput:
return m.viewport.View() + "\n" +
formHelpStyle.Render(" ↑↓/PgUp/PgDn 滚动 q/Esc 返回")
}
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
}