package main import ( "strings" "time" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" ) type VimMode int const ( NORMAL VimMode = iota INSERT VISUAL COMMAND SEARCH REPLACE ) // undoSnapshot — снимок состояния для undo/redo type undoSnapshot struct { lines []string cursorX int cursorY int } // Минимальная ширина панели preview const minPreviewWidth = 20 type EditorModel struct { // Контент lines []string cursorX int cursorY int // Vim режимы mode VimMode pendingKeys string visualStartY int // строка где начато VISUAL LINE выделение // Файл filepath string fileType string dirty bool lastSave time.Time statusMsg string // UI width int height int previewWidth int // Видимость панелей showPreview bool // Настройки showLineNumbers bool tabWidth int autosaveInterval int cfgShowPreview bool // Viewport viewport viewport.Model previewBuf string previewDirty bool // Команда commandBuffer string // Поиск и замена searchBuffer string searchMatches []struct { line, col int } searchIdx int lastSearchTerm string replaceBuffer string inReplace bool // Расширенная навигация lastFindChar rune lastFindIsForward bool numPrefix string // Clipboard clipboard string // Undo/Redo undoStack []undoSnapshot redoStack []undoSnapshot // insertUndoTaken — снимок для undo уже сделан в текущей INSERT-сессии. // Без этого saveUndoSnapshot() копировал весь []string на каждый // введённый символ. Реальный vim тоже группирует набор текста между // входом в INSERT и Esc в один undo-шаг — так что это одновременно // и оптимизация, и исправление поведения под ожидаемое от vim. insertUndoTaken bool // Help экран showHelp bool // Пасхалка showTzeentch bool tzeentchIdx int // Конфиг config Config } // Типы событий type TickMsg time.Time // NewEditor создаёт новый редактор func NewEditor(path, fileType, content string, cfg Config) *EditorModel { lines := strings.Split(content, "\n") if len(lines) == 0 { lines = []string{""} } m := &EditorModel{ lines: lines, filepath: path, fileType: fileType, dirty: false, mode: NORMAL, cursorX: 0, cursorY: 0, showLineNumbers: cfg.LineNumbers, tabWidth: cfg.TabWidth, autosaveInterval: cfg.AutosaveInterval, cfgShowPreview: cfg.ShowPreview, showPreview: cfg.ShowPreview && fileType == "md", lastSave: time.Now(), viewport: viewport.New(0, 0), width: 120, height: 30, previewWidth: cfg.PreviewWidth, previewDirty: true, config: cfg, } m.clampPanelWidths() m.updatePreview() return m } func (m *EditorModel) Init() tea.Cmd { return m.tickCmd() } func (m *EditorModel) tickCmd() tea.Cmd { interval := time.Duration(m.autosaveInterval) * time.Second if interval <= 0 { interval = 60 * time.Second } return tea.Tick(interval, func(t time.Time) tea.Msg { return TickMsg(t) }) } func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.clampPanelWidths() m.viewport.Width = m.editorWidth() m.viewport.Height = m.height - 3 return m, nil case tea.KeyMsg: // Bubble Tea использует scroll-region оптимизацию терминала при // построчном сдвиге видимой области текста. На некоторых терминалах // и мультиплексорах (замечено в Zellij) это приводит к артефактам: // при прокрутке вверх старая строка остаётся видна поверх новой, // потому что терминал "доскроллил" контент вместо полной перерисовки. // Решение: сравниваем видимый диапазон строк до и после обработки // клавиши, и если он сдвинулся — форсируем tea.ClearScreen, что // гарантирует полную перерисовку без scroll-оптимизаций. edHeight := m.height - 3 if edHeight < 1 { edHeight = 1 } prevStart, _ := visibleRange(m.cursorY, edHeight, len(m.lines)) newModel, cmd := m.handleKey(msg) em, ok := newModel.(*EditorModel) if !ok { return newModel, cmd } newStart, _ := visibleRange(em.cursorY, edHeight, len(em.lines)) if newStart != prevStart { return em, tea.Batch(cmd, tea.ClearScreen) } return em, cmd case TickMsg: if m.autosaveInterval > 0 && time.Since(m.lastSave) >= time.Duration(m.autosaveInterval)*time.Second && m.dirty { m.save() //nolint:errcheck } return m, m.tickCmd() } return m, nil } func (m *EditorModel) View() string { if m.showTzeentch { return renderTzeentch(m) } if m.showHelp { return renderHelp(m) } if m.showPreview && m.fileType == "md" { return renderSplit(m) } return renderEditor(m) } // === Ширины панелей === func (m *EditorModel) editorWidth() int { w := m.width if m.showPreview && m.fileType == "md" { w -= m.previewWidth } if w < 20 { w = 20 } return w } func (m *EditorModel) clampPanelWidths() { if m.previewWidth < minPreviewWidth { m.previewWidth = minPreviewWidth } if m.showPreview && m.fileType == "md" { needed := 20 + m.previewWidth if needed > m.width && m.width > 0 { m.previewWidth = m.width - 20 if m.previewWidth < minPreviewWidth { m.previewWidth = minPreviewWidth } } } } func (m *EditorModel) resizePreview(delta int) { m.previewWidth += delta if m.previewWidth < minPreviewWidth { m.previewWidth = minPreviewWidth } max := m.width - 20 if m.previewWidth > max { m.previewWidth = max } } // === Открытие файла === func (m *EditorModel) openFile(path string) { content, err := LoadFile(path) if err != nil { m.SetStatusMsg("Error opening: " + err.Error()) return } fileType := DetectFileType(path) lines := strings.Split(content, "\n") if len(lines) == 0 { lines = []string{""} } m.filepath = path m.fileType = fileType m.lines = lines m.cursorX = 0 m.cursorY = 0 m.dirty = false m.undoStack = nil m.redoStack = nil m.insertUndoTaken = false m.searchMatches = nil m.numPrefix = "" m.pendingKeys = "" m.showPreview = fileType == "md" && m.cfgShowPreview m.previewDirty = true m.mode = NORMAL m.clampPanelWidths() m.SetStatusMsg("Opened: " + path) m.updatePreview() } // === Вспомогательные методы === func (m *EditorModel) IsInsertMode() bool { return m.mode == INSERT } func (m *EditorModel) SetStatusMsg(msg string) { m.statusMsg = msg } func (m *EditorModel) MarkDirty() { m.dirty = true m.previewDirty = true // Кеш подсветки НЕ сбрасываем: ключ кеша уже включает содержимое строки // (highlightCacheKey{line, fileType}), так что неизменившиеся строки // остаются валидными в кеше сами по себе. Полный сброс на каждый // keystroke означал, что все видимые строки перевычислялись заново // после ввода одного символа — реальный источник лагов на больших файлах. } func (m *EditorModel) CursorWithinBounds() { if m.cursorY < 0 { m.cursorY = 0 } if m.cursorY >= len(m.lines) { m.cursorY = len(m.lines) - 1 } maxX := len([]rune(m.lines[m.cursorY])) if m.mode != INSERT && maxX > 0 { maxX-- } if m.cursorX > maxX { m.cursorX = maxX } if m.cursorX < 0 { m.cursorX = 0 } } // === Undo / Redo === func (m *EditorModel) undoLimit() int { n := len(m.lines) switch { case n > 50000: return 5 case n > 5000: return 20 default: return 100 } } func (m *EditorModel) saveUndoSnapshot() { snap := undoSnapshot{ lines: make([]string, len(m.lines)), cursorX: m.cursorX, cursorY: m.cursorY, } copy(snap.lines, m.lines) limit := m.undoLimit() m.undoStack = append(m.undoStack, snap) if len(m.undoStack) > limit { m.undoStack = m.undoStack[len(m.undoStack)-limit:] } m.redoStack = nil } // saveInsertUndoSnapshot берёт снимок для undo не чаще одного раза за // INSERT-сессию (от входа в режим до Esc), а не на каждый введённый символ. // Так делает и настоящий vim: набор текста целиком отменяется одним `u`. func (m *EditorModel) saveInsertUndoSnapshot() { if m.insertUndoTaken { return } m.saveUndoSnapshot() m.insertUndoTaken = true } func (m *EditorModel) Undo() { if len(m.undoStack) == 0 { m.SetStatusMsg("Already at oldest change") return } redoSnap := undoSnapshot{ lines: make([]string, len(m.lines)), cursorX: m.cursorX, cursorY: m.cursorY, } copy(redoSnap.lines, m.lines) m.redoStack = append(m.redoStack, redoSnap) snap := m.undoStack[len(m.undoStack)-1] m.undoStack = m.undoStack[:len(m.undoStack)-1] m.lines = snap.lines m.cursorX = snap.cursorX m.cursorY = snap.cursorY m.CursorWithinBounds() m.dirty = true m.previewDirty = true m.SetStatusMsg("Undo") } func (m *EditorModel) Redo() { if len(m.redoStack) == 0 { m.SetStatusMsg("Already at newest change") return } snap := m.redoStack[len(m.redoStack)-1] m.redoStack = m.redoStack[:len(m.redoStack)-1] undoSnap := undoSnapshot{ lines: make([]string, len(m.lines)), cursorX: m.cursorX, cursorY: m.cursorY, } copy(undoSnap.lines, m.lines) m.undoStack = append(m.undoStack, undoSnap) m.lines = snap.lines m.cursorX = snap.cursorX m.cursorY = snap.cursorY m.CursorWithinBounds() m.dirty = true m.previewDirty = true m.SetStatusMsg("Redo") }