futriis/internal/cli/prompt.go

424 lines
13 KiB
Go
Raw 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.

// /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
}