424 lines
13 KiB
Go
424 lines
13 KiB
Go
|
|
// /futriis/internal/cli/prompt.go
|
|||
|
|
// Пакет cli реализует интерактивное приглашение командной строки с поддержкой истории.
|
|||
|
|
// Обеспечивает редактирование строки ввода, навигацию по истории стрелками,
|
|||
|
|
// поддержку Unicode (включая кириллицу) и управление курсором терминала.
|
|||
|
|
// Использует raw режим терминала для обработки специальных клавиш.
|
|||
|
|
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"bufio"
|
|||
|
|
"fmt"
|
|||
|
|
"os"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
"futriis/pkg/utils"
|
|||
|
|
"github.com/mattn/go-runewidth"
|
|||
|
|
"golang.org/x/term"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// ANSI escape sequences для управления курсором
|
|||
|
|
const (
|
|||
|
|
ansiHideCursor = "\033[?25l"
|
|||
|
|
ansiShowCursor = "\033[?25h"
|
|||
|
|
ansiClearLine = "\033[2K"
|
|||
|
|
ansiCarriageReturn = "\r"
|
|||
|
|
ansiSaveCursor = "\033[s"
|
|||
|
|
ansiRestoreCursor = "\033[u"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Prompt представляет интерактивное приглашение
|
|||
|
|
type Prompt struct {
|
|||
|
|
history *History
|
|||
|
|
buffer []rune
|
|||
|
|
pos int
|
|||
|
|
searchMode bool
|
|||
|
|
searchQuery []rune
|
|||
|
|
searchResults []string
|
|||
|
|
searchPos int
|
|||
|
|
savedBuffer []rune // Сохраненный буфер для возврата из режима поиска
|
|||
|
|
savedPos int // Сохраненная позиция для возврата из режима поиска
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewPrompt создаёт новое приглашение
|
|||
|
|
func NewPrompt() *Prompt {
|
|||
|
|
return &Prompt{
|
|||
|
|
history: NewHistory(1000),
|
|||
|
|
buffer: make([]rune, 0),
|
|||
|
|
pos: 0,
|
|||
|
|
searchMode: false,
|
|||
|
|
searchQuery: make([]rune, 0),
|
|||
|
|
searchResults: make([]string, 0),
|
|||
|
|
searchPos: -1,
|
|||
|
|
savedBuffer: make([]rune, 0),
|
|||
|
|
savedPos: 0,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// readEscapeSequence читает полную escape-последовательность
|
|||
|
|
func (p *Prompt) readEscapeSequence(reader *bufio.Reader) (string, error) {
|
|||
|
|
seq := make([]byte, 0)
|
|||
|
|
seq = append(seq, 27) // ESC
|
|||
|
|
|
|||
|
|
// Читаем до тех пор, пока не получим полную последовательность
|
|||
|
|
for {
|
|||
|
|
b, err := reader.ReadByte()
|
|||
|
|
if err != nil {
|
|||
|
|
return string(seq), err
|
|||
|
|
}
|
|||
|
|
seq = append(seq, b)
|
|||
|
|
|
|||
|
|
// Проверяем, является ли это концом последовательности
|
|||
|
|
// Обычно escape-последовательности заканчиваются буквой
|
|||
|
|
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || b == '~' {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return string(seq), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ReadLine читает строку с поддержкой истории и редактирования
|
|||
|
|
func (p *Prompt) ReadLine() (string, error) {
|
|||
|
|
// Проверяем, является ли stdin терминалом
|
|||
|
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|||
|
|
return p.readSimple()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
|||
|
|
if err != nil {
|
|||
|
|
return p.readSimple()
|
|||
|
|
}
|
|||
|
|
defer term.Restore(int(os.Stdin.Fd()), oldState)
|
|||
|
|
|
|||
|
|
// Скрываем курсор в начале строки
|
|||
|
|
fmt.Print(ansiHideCursor)
|
|||
|
|
|
|||
|
|
// Очищаем буфер
|
|||
|
|
p.buffer = make([]rune, 0)
|
|||
|
|
p.pos = 0
|
|||
|
|
p.searchMode = false
|
|||
|
|
p.searchQuery = make([]rune, 0)
|
|||
|
|
p.searchResults = make([]string, 0)
|
|||
|
|
p.searchPos = -1
|
|||
|
|
p.savedBuffer = make([]rune, 0)
|
|||
|
|
p.savedPos = 0
|
|||
|
|
|
|||
|
|
// Сбрасываем позицию истории
|
|||
|
|
p.history.Reset()
|
|||
|
|
|
|||
|
|
// Показываем приглашение
|
|||
|
|
promptStr := utils.ColorPrompt + "futriis:~> " + utils.ColorReset
|
|||
|
|
fmt.Print(promptStr)
|
|||
|
|
|
|||
|
|
// Показываем курсор только после приглашения (в месте ввода)
|
|||
|
|
fmt.Print(ansiShowCursor)
|
|||
|
|
|
|||
|
|
reader := bufio.NewReader(os.Stdin)
|
|||
|
|
|
|||
|
|
for {
|
|||
|
|
r, _, err := reader.ReadRune()
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
switch r {
|
|||
|
|
case 3: // Ctrl+C
|
|||
|
|
fmt.Println("^C")
|
|||
|
|
return "", nil
|
|||
|
|
|
|||
|
|
case 4: // Ctrl+D
|
|||
|
|
if len(p.buffer) == 0 {
|
|||
|
|
fmt.Println()
|
|||
|
|
return "exit", nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case 13, 10: // Enter
|
|||
|
|
// Скрываем курсор перед завершением
|
|||
|
|
fmt.Print(ansiHideCursor)
|
|||
|
|
fmt.Println()
|
|||
|
|
cmd := string(p.buffer)
|
|||
|
|
if cmd != "" {
|
|||
|
|
p.history.Add(cmd)
|
|||
|
|
}
|
|||
|
|
p.history.Reset()
|
|||
|
|
|
|||
|
|
// Логируем команду
|
|||
|
|
if logger := utils.GetLogger(); logger != nil && cmd != "" {
|
|||
|
|
logger.Log("CMD", cmd)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return cmd, nil
|
|||
|
|
|
|||
|
|
case 127, 8: // Backspace (127 для большинства терминалов, 8 для некоторых)
|
|||
|
|
if p.searchMode {
|
|||
|
|
if len(p.searchQuery) > 0 {
|
|||
|
|
p.searchQuery = p.searchQuery[:len(p.searchQuery)-1]
|
|||
|
|
p.performSearch()
|
|||
|
|
} else {
|
|||
|
|
// Выходим из режима поиска
|
|||
|
|
p.searchMode = false
|
|||
|
|
p.searchResults = make([]string, 0)
|
|||
|
|
p.searchPos = -1
|
|||
|
|
// Восстанавливаем сохраненный буфер
|
|||
|
|
p.buffer = make([]rune, len(p.savedBuffer))
|
|||
|
|
copy(p.buffer, p.savedBuffer)
|
|||
|
|
p.pos = p.savedPos
|
|||
|
|
}
|
|||
|
|
} else if p.pos > 0 {
|
|||
|
|
// Удаляем символ перед курсором
|
|||
|
|
p.buffer = append(p.buffer[:p.pos-1], p.buffer[p.pos:]...)
|
|||
|
|
p.pos--
|
|||
|
|
}
|
|||
|
|
p.refreshLine()
|
|||
|
|
|
|||
|
|
case 18: // Ctrl+R - поиск по истории
|
|||
|
|
if !p.searchMode {
|
|||
|
|
// Сохраняем текущий буфер перед входом в режим поиска
|
|||
|
|
p.savedBuffer = make([]rune, len(p.buffer))
|
|||
|
|
copy(p.savedBuffer, p.buffer)
|
|||
|
|
p.savedPos = p.pos
|
|||
|
|
}
|
|||
|
|
p.searchMode = true
|
|||
|
|
p.searchQuery = make([]rune, 0)
|
|||
|
|
p.searchResults = p.history.Search("")
|
|||
|
|
p.searchPos = -1
|
|||
|
|
p.buffer = make([]rune, 0)
|
|||
|
|
p.pos = 0
|
|||
|
|
p.refreshLine()
|
|||
|
|
|
|||
|
|
case 27: // Escape sequence (стрелки и другие специальные клавиши)
|
|||
|
|
// Читаем полную escape-последовательность
|
|||
|
|
seq, err := p.readEscapeSequence(reader)
|
|||
|
|
if err != nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Обрабатываем различные escape-последовательности
|
|||
|
|
switch seq {
|
|||
|
|
case "\033[A", "\033OA": // Up arrow
|
|||
|
|
if p.searchMode {
|
|||
|
|
// Навигация по результатам поиска вверх
|
|||
|
|
if len(p.searchResults) > 0 && p.searchPos < len(p.searchResults)-1 {
|
|||
|
|
p.searchPos++
|
|||
|
|
p.buffer = []rune(p.searchResults[p.searchPos])
|
|||
|
|
p.pos = len(p.buffer)
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Обычная навигация по истории
|
|||
|
|
prevCmd := p.history.GetPrevious()
|
|||
|
|
if prevCmd != "" {
|
|||
|
|
p.buffer = []rune(prevCmd)
|
|||
|
|
p.pos = len(p.buffer)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
p.refreshLine()
|
|||
|
|
|
|||
|
|
case "\033[B", "\033OB": // Down arrow
|
|||
|
|
if p.searchMode {
|
|||
|
|
// Навигация по результатам поиска вниз
|
|||
|
|
if p.searchPos > 0 {
|
|||
|
|
p.searchPos--
|
|||
|
|
p.buffer = []rune(p.searchResults[p.searchPos])
|
|||
|
|
p.pos = len(p.buffer)
|
|||
|
|
} else if p.searchPos == 0 {
|
|||
|
|
p.searchPos = -1
|
|||
|
|
p.buffer = make([]rune, len(p.searchQuery))
|
|||
|
|
copy(p.buffer, p.searchQuery)
|
|||
|
|
p.pos = len(p.buffer)
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Обычная навигация по истории
|
|||
|
|
nextCmd := p.history.GetNext()
|
|||
|
|
if nextCmd != "" {
|
|||
|
|
p.buffer = []rune(nextCmd)
|
|||
|
|
p.pos = len(p.buffer)
|
|||
|
|
} else {
|
|||
|
|
// Достигли конца истории, очищаем буфер
|
|||
|
|
p.buffer = make([]rune, 0)
|
|||
|
|
p.pos = 0
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
p.refreshLine()
|
|||
|
|
|
|||
|
|
case "\033[C", "\033OC": // Right arrow
|
|||
|
|
if !p.searchMode && p.pos < len(p.buffer) {
|
|||
|
|
p.pos++
|
|||
|
|
p.refreshLine()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case "\033[D", "\033OD": // Left arrow
|
|||
|
|
if !p.searchMode && p.pos > 0 {
|
|||
|
|
p.pos--
|
|||
|
|
p.refreshLine()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case "\033[H", "\033OH": // Home
|
|||
|
|
if !p.searchMode {
|
|||
|
|
p.pos = 0
|
|||
|
|
p.refreshLine()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case "\033[F", "\033OF": // End
|
|||
|
|
if !p.searchMode {
|
|||
|
|
p.pos = len(p.buffer)
|
|||
|
|
p.refreshLine()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case "\033[3~": // Delete
|
|||
|
|
if !p.searchMode && p.pos < len(p.buffer) {
|
|||
|
|
p.buffer = append(p.buffer[:p.pos], p.buffer[p.pos+1:]...)
|
|||
|
|
p.refreshLine()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
default:
|
|||
|
|
// Добавляем символ (поддержка Unicode, включая русский)
|
|||
|
|
if r >= 32 { // Печатные символы
|
|||
|
|
if p.searchMode {
|
|||
|
|
// Режим поиска - добавляем символ в поисковый запрос
|
|||
|
|
p.searchQuery = append(p.searchQuery, r)
|
|||
|
|
p.performSearch()
|
|||
|
|
} else {
|
|||
|
|
// Обычный режим ввода
|
|||
|
|
if p.pos == len(p.buffer) {
|
|||
|
|
p.buffer = append(p.buffer, r)
|
|||
|
|
} else {
|
|||
|
|
// Вставляем символ в позицию курсора
|
|||
|
|
newBuffer := make([]rune, len(p.buffer)+1)
|
|||
|
|
copy(newBuffer, p.buffer[:p.pos])
|
|||
|
|
newBuffer[p.pos] = r
|
|||
|
|
copy(newBuffer[p.pos+1:], p.buffer[p.pos:])
|
|||
|
|
p.buffer = newBuffer
|
|||
|
|
}
|
|||
|
|
p.pos++
|
|||
|
|
}
|
|||
|
|
p.refreshLine()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// performSearch выполняет поиск по истории команд
|
|||
|
|
func (p *Prompt) performSearch() {
|
|||
|
|
query := string(p.searchQuery)
|
|||
|
|
p.searchResults = p.history.Search(query)
|
|||
|
|
|
|||
|
|
if len(p.searchResults) > 0 {
|
|||
|
|
// Показываем последний найденный результат
|
|||
|
|
p.searchPos = len(p.searchResults) - 1
|
|||
|
|
p.buffer = []rune(p.searchResults[p.searchPos])
|
|||
|
|
p.pos = len(p.buffer)
|
|||
|
|
} else {
|
|||
|
|
// Если ничего не найдено, показываем сам запрос
|
|||
|
|
p.buffer = make([]rune, len(p.searchQuery))
|
|||
|
|
copy(p.buffer, p.searchQuery)
|
|||
|
|
p.pos = len(p.buffer)
|
|||
|
|
p.searchPos = -1
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// refreshLine обновляет текущую строку с правильным позиционированием курсора
|
|||
|
|
func (p *Prompt) refreshLine() {
|
|||
|
|
// Скрываем курсор во время перерисовки
|
|||
|
|
fmt.Print(ansiHideCursor)
|
|||
|
|
|
|||
|
|
// Возврат в начало строки и очистка
|
|||
|
|
fmt.Print(ansiCarriageReturn + ansiClearLine)
|
|||
|
|
|
|||
|
|
if p.searchMode {
|
|||
|
|
// Режим поиска - показываем специальное приглашение
|
|||
|
|
searchPrompt := fmt.Sprintf("(reverse-i-search)`%s': ", string(p.searchQuery))
|
|||
|
|
fmt.Print(searchPrompt)
|
|||
|
|
|
|||
|
|
// Печатаем текущий буфер (найденный результат или запрос)
|
|||
|
|
if len(p.buffer) > 0 {
|
|||
|
|
// Подсвечиваем найденную подстроку
|
|||
|
|
queryStr := string(p.searchQuery)
|
|||
|
|
bufferStr := string(p.buffer)
|
|||
|
|
|
|||
|
|
if queryStr != "" && strings.Contains(strings.ToLower(bufferStr), strings.ToLower(queryStr)) {
|
|||
|
|
// Находим позицию первого вхождения
|
|||
|
|
lowerBuffer := strings.ToLower(bufferStr)
|
|||
|
|
lowerQuery := strings.ToLower(queryStr)
|
|||
|
|
idx := strings.Index(lowerBuffer, lowerQuery)
|
|||
|
|
|
|||
|
|
if idx >= 0 {
|
|||
|
|
// Выводим часть до совпадения
|
|||
|
|
fmt.Print(bufferStr[:idx])
|
|||
|
|
// Выводим совпадение инвертированными цветами
|
|||
|
|
fmt.Print(utils.ColorReverse + bufferStr[idx:idx+len(queryStr)] + utils.ColorReset)
|
|||
|
|
// Выводим остаток
|
|||
|
|
fmt.Print(bufferStr[idx+len(queryStr):])
|
|||
|
|
} else {
|
|||
|
|
fmt.Print(bufferStr)
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
fmt.Print(bufferStr)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Вычисляем ширину поискового приглашения
|
|||
|
|
promptWidth := runewidth.StringWidth(searchPrompt)
|
|||
|
|
|
|||
|
|
// Вычисляем позицию курсора
|
|||
|
|
cursorPos := promptWidth
|
|||
|
|
if len(p.searchQuery) > 0 {
|
|||
|
|
// Курсор должен быть после поискового запроса
|
|||
|
|
cursorPos += runewidth.StringWidth(string(p.searchQuery))
|
|||
|
|
} else {
|
|||
|
|
// Если запрос пустой, курсор в конце приглашения
|
|||
|
|
cursorPos = promptWidth
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Перемещаем курсор на правильную позицию
|
|||
|
|
fmt.Printf("\033[%dG", cursorPos+1)
|
|||
|
|
} else {
|
|||
|
|
// Обычный режим - показываем стандартное приглашение
|
|||
|
|
promptStr := utils.ColorPrompt + "futriis:~> " + utils.ColorReset
|
|||
|
|
fmt.Print(promptStr)
|
|||
|
|
|
|||
|
|
// Печатаем текущий буфер
|
|||
|
|
if len(p.buffer) > 0 {
|
|||
|
|
fmt.Print(string(p.buffer))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Вычисляем ширину приглашения
|
|||
|
|
promptWidth := runewidth.StringWidth("futriis:~> ")
|
|||
|
|
|
|||
|
|
// Вычисляем позицию курсора
|
|||
|
|
cursorPos := promptWidth
|
|||
|
|
for i := 0; i < p.pos; i++ {
|
|||
|
|
cursorPos += runewidth.RuneWidth(p.buffer[i])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Перемещаем курсор на правильную позицию
|
|||
|
|
fmt.Printf("\033[%dG", cursorPos+1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Показываем курсор
|
|||
|
|
fmt.Print(ansiShowCursor)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// readSimple читает строку без специальной обработки (fallback)
|
|||
|
|
func (p *Prompt) readSimple() (string, error) {
|
|||
|
|
fmt.Print(utils.GetPrompt())
|
|||
|
|
reader := bufio.NewReader(os.Stdin)
|
|||
|
|
|
|||
|
|
cmd, err := reader.ReadString('\n')
|
|||
|
|
if err == nil {
|
|||
|
|
cmd = strings.TrimRight(cmd, "\r\n")
|
|||
|
|
// Логируем команду
|
|||
|
|
if logger := utils.GetLogger(); logger != nil && cmd != "" {
|
|||
|
|
logger.Log("CMD", cmd)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Добавляем команду в историю
|
|||
|
|
if cmd != "" {
|
|||
|
|
p.history.Add(cmd)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return cmd, err
|
|||
|
|
}
|