420 lines
No EOL
10 KiB
Go
Executable file
420 lines
No EOL
10 KiB
Go
Executable file
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")
|
||
} |