From b56b767bd1f2ceab8eb90f4230f6d53f2f2e0a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=93=D1=80=D0=B8=D0=B3=D0=BE=D1=80=D0=B8=D0=B9=20=D0=A1?= =?UTF-8?q?=D0=B0=D1=84=D1=80=D0=BE=D0=BD=D0=BE=D0=B2?= Date: Sun, 1 Mar 2026 00:47:35 +0000 Subject: [PATCH] Upload files to "internal/cli" --- internal/cli/prompt.go | 423 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 internal/cli/prompt.go diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go new file mode 100644 index 0000000..795e5a5 --- /dev/null +++ b/internal/cli/prompt.go @@ -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 +}