Upload files to "internal/cli"

This commit is contained in:
Григорий Сафронов 2026-03-01 00:47:35 +00:00
parent a3b78f5946
commit b56b767bd1

423
internal/cli/prompt.go Normal file
View File

@ -0,0 +1,423 @@
// /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
}