457 lines
10 KiB
Go
457 lines
10 KiB
Go
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")
|
||
}
|