package main import ( "path/filepath" "strings" "time" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" ) type VimMode int const ( NORMAL VimMode = iota INSERT VISUAL COMMAND SEARCH REPLACE ) // PanelFocus — какая панель принимает клавиши type PanelFocus int const ( FocusEditor PanelFocus = iota FocusFileTree ) // undoSnapshot — снимок состояния для undo/redo type undoSnapshot struct { lines []string cursorX int cursorY int } // Минимальные ширины панелей const ( minEditorWidth = 20 minTreeWidth = 15 minPreviewWidth = 20 ) type EditorModel struct { // Контент lines []string cursorX int cursorY int // Vim режимы mode VimMode pendingKeys string selection struct { startX, startY int endX, endY int } // Файл filepath string fileType string dirty bool lastSave time.Time statusMsg string // UI — размеры width int height int treeWidth int previewWidth int // Видимость панелей showFileTree bool showPreview bool // Фокус focus PanelFocus // Настройки (из конфига или :set) showLineNumbers bool tabWidth int autosaveInterval int // секунды cfgShowPreview bool // дефолт для preview из конфига // Viewport viewport viewport.Model previewBuf string // Команда 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 // Файловое дерево fileTree FileTree // Help экран showHelp bool // Конфиг (хранится для :config show) 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{""} } rootDir := filepath.Dir(path) if rootDir == "" || rootDir == "." { rootDir, _ = filepath.Abs(".") } 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" || fileType == "md" && cfg.ShowPreview, showFileTree: cfg.ShowTree, focus: FocusEditor, lastSave: time.Now(), viewport: viewport.New(0, 0), width: 120, height: 30, treeWidth: cfg.TreeWidth, previewWidth: cfg.PreviewWidth, config: cfg, fileTree: NewFileTree(rootDir), } // Для markdown включаем preview если задано в конфиге или по умолчанию if fileType == "md" && cfg.ShowPreview { m.showPreview = true } if cfg.ShowTree { m.fileTree.reload() m.fileTree.focused = false } m.clampPanelWidths() m.updatePreview() return m } // Init инициализирует модель 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 // fallback } return tea.Tick(interval, func(t time.Time) tea.Msg { return TickMsg(t) }) } // Update обрабатывает сообщения 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: return m.handleKey(msg) 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 } // View возвращает строку для вывода func (m *EditorModel) View() string { if m.showHelp { return renderHelp(m) } switch { case m.showFileTree && m.showPreview && m.fileType == "md": return m.viewTreeEditorPreview() case m.showFileTree: return m.viewTreeEditor() case m.showPreview && m.fileType == "md": return m.viewEditorPreview() default: return m.viewEditorOnly() } } func (m *EditorModel) viewEditorOnly() string { return renderEditor(m) } func (m *EditorModel) viewEditorPreview() string { return renderSplit(m) } func (m *EditorModel) viewTreeEditor() string { return renderTreeEditor(m) } func (m *EditorModel) viewTreeEditorPreview() string { return renderTreeEditorPreview(m) } // === Вычисление ширин === func (m *EditorModel) editorWidth() int { w := m.width if m.showFileTree { w -= m.treeWidth } if m.showPreview && m.fileType == "md" { w -= m.previewWidth } if w < minEditorWidth { w = minEditorWidth } return w } func (m *EditorModel) clampPanelWidths() { if m.treeWidth < minTreeWidth { m.treeWidth = minTreeWidth } if m.previewWidth < minPreviewWidth { m.previewWidth = minPreviewWidth } needed := minEditorWidth if m.showFileTree { needed += m.treeWidth } if m.showPreview && m.fileType == "md" { needed += m.previewWidth } if needed > m.width && m.width > 0 { available := m.width - minEditorWidth if available < 0 { available = 0 } if m.showFileTree && m.showPreview && m.fileType == "md" { total := m.treeWidth + m.previewWidth if total > 0 { m.treeWidth = available * m.treeWidth / total m.previewWidth = available - m.treeWidth } if m.treeWidth < minTreeWidth { m.treeWidth = minTreeWidth } if m.previewWidth < minPreviewWidth { m.previewWidth = minPreviewWidth } } else if m.showFileTree { m.treeWidth = available if m.treeWidth < minTreeWidth { m.treeWidth = minTreeWidth } } else if m.showPreview { m.previewWidth = available if m.previewWidth < minPreviewWidth { m.previewWidth = minPreviewWidth } } } } func (m *EditorModel) resizeTree(delta int) { m.treeWidth += delta if m.treeWidth < minTreeWidth { m.treeWidth = minTreeWidth } maxTree := m.width - minEditorWidth if m.showPreview && m.fileType == "md" { maxTree -= m.previewWidth } if m.treeWidth > maxTree { m.treeWidth = maxTree } } func (m *EditorModel) resizePreview(delta int) { m.previewWidth += delta if m.previewWidth < minPreviewWidth { m.previewWidth = minPreviewWidth } maxPreview := m.width - minEditorWidth if m.showFileTree { maxPreview -= m.treeWidth } if m.previewWidth > maxPreview { m.previewWidth = maxPreview } } // === Открытие файла из дерева === 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.searchMatches = nil m.numPrefix = "" m.pendingKeys = "" m.showPreview = fileType == "md" && m.cfgShowPreview 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 } 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])) // В NORMAL режиме курсор не может стоять за последним символом 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) saveUndoSnapshot() { snapshot := undoSnapshot{ lines: make([]string, len(m.lines)), cursorX: m.cursorX, cursorY: m.cursorY, } copy(snapshot.lines, m.lines) m.undoStack = append(m.undoStack, snapshot) if len(m.undoStack) > 100 { m.undoStack = m.undoStack[1:] } m.redoStack = nil } 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 = make([]string, len(snap.lines)) copy(m.lines, snap.lines) m.cursorX = snap.cursorX m.cursorY = snap.cursorY m.CursorWithinBounds() m.dirty = 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 = make([]string, len(snap.lines)) copy(m.lines, snap.lines) m.cursorX = snap.cursorX m.cursorY = snap.cursorY m.CursorWithinBounds() m.dirty = true m.SetStatusMsg("Redo") }