forked from Gitlink/gitlink-cli
434 lines
11 KiB
Go
434 lines
11 KiB
Go
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
|
||
}
|