diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md old mode 100644 new mode 100755 diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/commands.go b/commands.go old mode 100644 new mode 100755 index 05e40f5..c8ffffd --- a/commands.go +++ b/commands.go @@ -76,21 +76,6 @@ func (m *EditorModel) executeCommand(cmd string) tea.Cmd { return nil } - // :treewidth N / :tw N - if strings.HasPrefix(cmd, "treewidth ") || strings.HasPrefix(cmd, "tw ") { - parts := strings.Fields(cmd) - if len(parts) == 2 { - if n, err := strconv.Atoi(parts[1]); err == nil { - m.treeWidth = n - m.clampPanelWidths() - m.SetStatusMsg(fmt.Sprintf("Tree width: %d", m.treeWidth)) - return nil - } - } - m.SetStatusMsg("Usage: treewidth ") - return nil - } - // :previewwidth N / :pw N if strings.HasPrefix(cmd, "previewwidth ") || strings.HasPrefix(cmd, "pw ") { parts := strings.Fields(cmd) @@ -112,6 +97,10 @@ func (m *EditorModel) executeCommand(cmd string) tea.Cmd { m.showHelp = true return nil + case "tzeentch": + m.triggerTzeentch() + return nil + case "w": if err := m.save(); err != nil { m.SetStatusMsg("Error: " + err.Error()) @@ -143,21 +132,6 @@ func (m *EditorModel) executeCommand(cmd string) tea.Cmd { m.Undo() return nil - case "tree": - m.showFileTree = true - m.fileTree.reload() - m.focus = FocusFileTree - m.fileTree.focused = true - m.clampPanelWidths() - m.SetStatusMsg(fmt.Sprintf("File tree ON (width: %d)", m.treeWidth)) - return nil - - case "notree": - m.showFileTree = false - m.focus = FocusEditor - m.fileTree.focused = false - m.SetStatusMsg("File tree OFF") - return nil case "new": return m.cmdNew("") @@ -264,6 +238,7 @@ func (m *EditorModel) cmdNew(name string) tea.Cmd { m.redoStack = nil m.searchMatches = nil m.showPreview = m.cfgShowPreview + m.previewDirty = true m.mode = NORMAL m.updatePreview() m.SetStatusMsg("New file (save with :w )") @@ -371,8 +346,8 @@ func (m *EditorModel) handleConfigCommand(sub string) { switch sub { case "", "show": m.SetStatusMsg(fmt.Sprintf( - "Config: %s | tw=%d pw=%d nu=%v tab=%d autosave=%ds", - m.config.LoadedFrom, m.treeWidth, m.previewWidth, + "Config: %s | pw=%d nu=%v tab=%d autosave=%ds", + m.config.LoadedFrom, m.previewWidth, m.showLineNumbers, m.tabWidth, m.autosaveInterval, )) case "paths": @@ -440,9 +415,9 @@ func (m *EditorModel) handleSetCommand(cmd string) { rest := strings.TrimSpace(strings.TrimPrefix(cmd, "set")) if rest == "" { m.SetStatusMsg(fmt.Sprintf( - "nu=%v preview=%v tree=%v tw=%d pw=%d tab=%d autosave=%d", - m.showLineNumbers, m.showPreview, m.showFileTree, - m.treeWidth, m.previewWidth, m.tabWidth, m.autosaveInterval, + "nu=%v preview=%v pw=%d tab=%d autosave=%d", + m.showLineNumbers, m.showPreview, + m.previewWidth, m.tabWidth, m.autosaveInterval, )) return } @@ -490,15 +465,6 @@ func (m *EditorModel) handleSetCommand(cmd string) { } else { m.SetStatusMsg(fmt.Sprintf("Autosave every %ds", n)) } - case "treewidth", "tw": - n, err := strconv.Atoi(val) - if err != nil { - m.SetStatusMsg("Expected integer") - return - } - m.treeWidth = n - m.clampPanelWidths() - m.SetStatusMsg(fmt.Sprintf("Tree width: %d", m.treeWidth)) case "previewwidth", "pw": n, err := strconv.Atoi(val) if err != nil { @@ -522,4 +488,4 @@ func min3(a, b int) int { return a } return b -} +} \ No newline at end of file diff --git a/config.go b/config.go old mode 100644 new mode 100755 index 480f461..361b3ab --- a/config.go +++ b/config.go @@ -13,18 +13,15 @@ import ( // Config хранит все настройки редактора type Config struct { // [settings] - TreeWidth int PreviewWidth int LineNumbers bool TabWidth int AutosaveInterval int // секунды (0 = выключен) ShowPreview bool - ShowTree bool Theme string // [keys] — map: действие → клавиша - // Например: "toggle_tree" → "ctrl+b" - Keys map[string]string + Keys map[string]string // Путь откуда загружен конфиг (для отображения в :set) LoadedFrom string @@ -33,13 +30,11 @@ type Config struct { // DefaultConfig возвращает конфиг со значениями по умолчанию func DefaultConfig() Config { return Config{ - TreeWidth: 25, PreviewWidth: 40, LineNumbers: true, TabWidth: 4, AutosaveInterval: 60, ShowPreview: false, // включается автоматически для .md - ShowTree: false, Theme: "default", Keys: defaultKeys(), } @@ -51,7 +46,6 @@ func defaultKeys() map[string]string { "quit": "ctrl+c", "quit_alt": "ctrl+d", "redo": "ctrl+r", - "toggle_tree": "ctrl+b", "focus_switch": "tab", "panel_shrink": "<", "panel_grow": ">", @@ -241,13 +235,6 @@ func parseConfig(data []byte, cfg *Config) error { // applySettingKey применяет одну настройку из секции [settings] func applySettingKey(cfg *Config, key, value string, line int) error { switch key { - case "tree_width", "treewidth": - n, err := parseInt(value, line) - if err != nil { - return err - } - cfg.TreeWidth = n - case "preview_width", "previewwidth": n, err := parseInt(value, line) if err != nil { @@ -286,13 +273,6 @@ func applySettingKey(cfg *Config, key, value string, line int) error { } cfg.ShowPreview = b - case "show_tree", "tree": - b, err := parseBool(value, line) - if err != nil { - return err - } - cfg.ShowTree = b - case "theme": cfg.Theme = value @@ -309,7 +289,7 @@ func applyKeyBinding(cfg *Config, action, key string, line int) error { // Проверяем что действие известно known := []string{ - "quit", "quit_alt", "redo", "toggle_tree", "focus_switch", + "quit", "quit_alt", "redo", "panel_shrink", "panel_grow", "toggle_preview", } for _, a := range known { @@ -356,9 +336,6 @@ func WriteDefaultConfig(path string) error { [settings] -# Ширина панели файлового дерева (символы) -tree_width = 25 - # Ширина панели preview (символы) preview_width = 40 @@ -374,8 +351,6 @@ autosave = 60 # Показывать preview при открытии .md файлов (true/false) show_preview = false -# Показывать файловое дерево при запуске (true/false) -show_tree = false # Тема (default — единственная пока) theme = default @@ -393,8 +368,6 @@ quit_alt = ctrl+d # Redo redo = ctrl+r -# Показать/скрыть файловое дерево -toggle_tree = ctrl+b # Переключение фокуса между панелями focus_switch = tab @@ -413,20 +386,14 @@ toggle_preview = " " // ApplyConfig применяет загруженный конфиг к EditorModel func (m *EditorModel) ApplyConfig(cfg Config) { - m.treeWidth = cfg.TreeWidth m.previewWidth = cfg.PreviewWidth m.showLineNumbers = cfg.LineNumbers m.tabWidth = cfg.TabWidth m.autosaveInterval = cfg.AutosaveInterval - - if cfg.ShowTree { - m.showFileTree = true - m.fileTree.reload() - } // ShowPreview из конфига применяется только если тип файла md — // это проверяется при открытии файла, здесь только сохраняем флаг m.cfgShowPreview = cfg.ShowPreview m.config = cfg m.clampPanelWidths() -} +} \ No newline at end of file diff --git a/fetch.go b/fetch.go old mode 100644 new mode 100755 diff --git a/file.go b/file.go old mode 100644 new mode 100755 diff --git a/file_io.go b/file_io.go old mode 100644 new mode 100755 diff --git a/filetree.go b/filetree.go deleted file mode 100644 index 03526e2..0000000 --- a/filetree.go +++ /dev/null @@ -1,305 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "sort" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// === Стили файлового дерева === -var ( - styleTreeDir = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Bold(true) - styleTreeFile = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) - styleTreeSelected = lipgloss.NewStyle().Background(lipgloss.Color("237")).Foreground(lipgloss.Color("15")).Bold(true) - styleTreeHeader = lipgloss.NewStyle().Background(lipgloss.Color("8")).Foreground(lipgloss.Color("15")).Bold(true) - styleTreeCurrent = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) -) - -// FileNode — узел файлового дерева -type FileNode struct { - name string - path string - isDir bool - expanded bool - depth int - children []*FileNode -} - -// FileTree — состояние панели файлового дерева -type FileTree struct { - root string // корневая директория - nodes []*FileNode // плоский список видимых узлов - cursor int // индекс выбранного узла - scroll int // смещение прокрутки - focused bool // фокус на этой панели -} - -// NewFileTree создаёт новое дерево для директории rootDir -func NewFileTree(rootDir string) FileTree { - ft := FileTree{ - root: rootDir, - cursor: 0, - scroll: 0, - focused: false, - } - ft.reload() - return ft -} - -// reload перечитывает директорию и обновляет плоский список -func (ft *FileTree) reload() { - rootNode := &FileNode{ - name: filepath.Base(ft.root), - path: ft.root, - isDir: true, - expanded: true, - depth: 0, - } - loadChildren(rootNode) - ft.nodes = flattenTree(rootNode) -} - -// loadChildren рекурсивно загружает дочерние узлы -func loadChildren(node *FileNode) { - if !node.isDir { - return - } - - entries, err := os.ReadDir(node.path) - if err != nil { - return - } - - // Сортируем: сначала папки, потом файлы, алфавитно - sort.Slice(entries, func(i, j int) bool { - if entries[i].IsDir() != entries[j].IsDir() { - return entries[i].IsDir() - } - return entries[i].Name() < entries[j].Name() - }) - - node.children = nil - for _, e := range entries { - // Пропускаем скрытые файлы и типичный мусор - name := e.Name() - if strings.HasPrefix(name, ".") { - continue - } - if name == "node_modules" || name == "vendor" || name == "__pycache__" { - continue - } - - child := &FileNode{ - name: name, - path: filepath.Join(node.path, name), - isDir: e.IsDir(), - depth: node.depth + 1, - } - node.children = append(node.children, child) - } -} - -// flattenTree возвращает плоский список видимых узлов -func flattenTree(node *FileNode) []*FileNode { - result := []*FileNode{node} - if node.isDir && node.expanded { - for _, child := range node.children { - result = append(result, flattenTree(child)...) - } - } - return result -} - -// toggle разворачивает/сворачивает директорию под курсором -func (ft *FileTree) toggle() { - if ft.cursor >= len(ft.nodes) { - return - } - node := ft.nodes[ft.cursor] - if !node.isDir { - return - } - node.expanded = !node.expanded - if node.expanded && len(node.children) == 0 { - loadChildren(node) - } - ft.rebuildFlat() -} - -// rebuildFlat пересобирает плоский список из корня -func (ft *FileTree) rebuildFlat() { - if len(ft.nodes) == 0 { - return - } - root := ft.nodes[0] - ft.nodes = flattenTree(root) - if ft.cursor >= len(ft.nodes) { - ft.cursor = len(ft.nodes) - 1 - } -} - -// moveUp перемещает курсор вверх -func (ft *FileTree) moveUp(n int) { - ft.cursor -= n - if ft.cursor < 0 { - ft.cursor = 0 - } - ft.adjustScroll(0) -} - -// moveDown перемещает курсор вниз -func (ft *FileTree) moveDown(n int) { - ft.cursor += n - if ft.cursor >= len(ft.nodes) { - ft.cursor = len(ft.nodes) - 1 - } - ft.adjustScroll(0) -} - -// adjustScroll корректирует scroll чтобы cursor был виден -func (ft *FileTree) adjustScroll(height int) { - if height <= 0 { - return - } - if ft.cursor < ft.scroll { - ft.scroll = ft.cursor - } - if ft.cursor >= ft.scroll+height { - ft.scroll = ft.cursor - height + 1 - } -} - -// selectedPath возвращает путь выбранного узла (или "" если курсор вне границ) -func (ft *FileTree) selectedPath() string { - if ft.cursor < 0 || ft.cursor >= len(ft.nodes) { - return "" - } - return ft.nodes[ft.cursor].path -} - -// selectedIsDir возвращает true если выбранный узел — директория -func (ft *FileTree) selectedIsDir() bool { - if ft.cursor < 0 || ft.cursor >= len(ft.nodes) { - return false - } - return ft.nodes[ft.cursor].isDir -} - -// renderFileTree рендерит панель файлового дерева -func renderFileTree(m *EditorModel) string { - ft := &m.fileTree - width := m.treeWidth - height := m.height - 3 - - var output strings.Builder - - // Заголовок - title := filepath.Base(ft.root) - if len(title) > width-4 { - title = title[:width-4] - } - headerStyle := styleTreeHeader.Copy().Width(width).Padding(0, 1) - if ft.focused { - headerStyle = headerStyle.Background(lipgloss.Color("4")) - } - output.WriteString(headerStyle.Render(" 📁 " + title)) - output.WriteString("\n") - - ft.adjustScroll(height) - - // Список файлов - for i := ft.scroll; i < ft.scroll+height && i < len(ft.nodes); i++ { - node := ft.nodes[i] - line := renderFileNode(node, m.filepath, width) - - if i == ft.cursor && ft.focused { - line = styleTreeSelected.Render(line) - } else if i == ft.cursor { - line = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Render(line) - } - // plain строки — без Width() рендера, joinColumns дополнит пробелами - - output.WriteString(line) - output.WriteString("\n") - } - - // Заполнить пустые строки - rendered := min(height, max(0, len(ft.nodes)-ft.scroll)) - for i := rendered; i < height; i++ { - output.WriteString("\n") - } - - // Нижняя строка с подсказкой - hint := " Enter:открыть Tab:фокус :ширина" - if len(hint) > width { - hint = hint[:width] - } - hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width) - output.WriteString(hintStyle.Render(hint)) - - return output.String() -} - -// renderFileNode рендерит одну строку узла -func renderFileNode(node *FileNode, currentFile string, width int) string { - // Отступ по глубине (корень не отступаем) - indent := "" - if node.depth > 0 { - indent = strings.Repeat(" ", node.depth-1) - } - - var icon, name string - - if node.isDir { - if node.expanded { - icon = "▾ " - } else { - icon = "▸ " - } - name = styleTreeDir.Render(node.name) - } else { - icon = " " - // Выделяем текущий открытый файл - if node.path == currentFile { - name = styleTreeCurrent.Render("● " + node.name) - } else { - name = styleTreeFile.Render(node.name) - } - } - - line := indent + icon + name - - // Обрезаем если не влезает (с учётом ANSI-кодов это приблизительно) - visibleLen := len(indent) + len(icon) + len(node.name) + 2 - if visibleLen > width { - // Обрезаем имя - maxName := width - len(indent) - len(icon) - 1 - if maxName < 3 { - maxName = 3 - } - truncated := node.name - if len(truncated) > maxName { - truncated = truncated[:maxName-1] + "…" - } - if node.isDir { - name = styleTreeDir.Render(truncated) - } else if node.path == currentFile { - name = styleTreeCurrent.Render("● " + truncated) - } else { - name = styleTreeFile.Render(truncated) - } - line = indent + icon + name - } - - return line -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/go.mod b/go.mod old mode 100644 new mode 100755 diff --git a/go.sum b/go.sum old mode 100644 new mode 100755 diff --git a/help.go b/help.go old mode 100644 new mode 100755 index 23f693c..f90b884 --- a/help.go +++ b/help.go @@ -57,12 +57,24 @@ var helpSections = []helpSection{ items: []helpItem{ {"x", "удалить символ под курсором"}, {"dd / 3dd", "удалить строку / 3 строки"}, + {"dw / de / db", "удалить до слова / конца слова / назад"}, {"yy", "скопировать строку"}, {"p / P", "вставить ниже / выше"}, {"u", "отменить (undo)"}, {"Ctrl+R", "повторить (redo)"}, }, }, + { + title: "ВЫДЕЛЕНИЕ (VISUAL LINE)", + items: []helpItem{ + {"V", "войти в режим выделения строк"}, + {"j / k", "расширить выделение вниз / вверх"}, + {"gg / G", "выделить до начала / конца файла"}, + {"d / x", "удалить выделенные строки"}, + {"y", "скопировать выделенные строки"}, + {"Esc / V", "выйти без действия"}, + }, + }, { title: "ФАЙЛ", items: []helpItem{ @@ -81,25 +93,11 @@ var helpSections = []helpSection{ { title: "ПАНЕЛИ", items: []helpItem{ - {"Ctrl+B", "показать / скрыть файловое дерево"}, - {"Tab", "переключить фокус между панелями"}, - {"< / >", "уменьшить / увеличить ширину панели"}, + {"< / >", "уменьшить / увеличить ширину preview"}, {"Пробел", "показать / скрыть preview (только .md)"}, - {":treewidth N", "установить ширину дерева"}, {":previewwidth N", "установить ширину preview"}, }, }, - { - title: "ФАЙЛОВОЕ ДЕРЕВО (при фокусе)", - items: []helpItem{ - {"j / k", "вниз / вверх"}, - {"l / Enter", "открыть файл / развернуть папку"}, - {"h", "свернуть папку / к родителю"}, - {"gg / G", "начало / конец списка"}, - {"r", "обновить дерево"}, - {"Esc", "вернуть фокус редактору"}, - }, - }, { title: "ЭКСПОРТ И ЗАГРУЗКА", items: []helpItem{ @@ -254,4 +252,4 @@ func renderHelp(m *EditorModel) string { return out.String() } -// visibleLen определена в render.go +// visibleLen определена в render.go \ No newline at end of file diff --git a/keybindings.go b/keybindings.go old mode 100644 new mode 100755 index 6459ddd..fb73f0c --- a/keybindings.go +++ b/keybindings.go @@ -1,83 +1,51 @@ package main import ( + "fmt" "strings" tea "github.com/charmbracelet/bubbletea" ) -// handleKey диспетчеризирует по фокусу и режиму func (m *EditorModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() - // === Help экран — обрабатываем первым === + // Попап Тзинча — любая клавиша закрывает + if m.showTzeentch { + m.showTzeentch = false + return m, nil + } + + // Help экран if m.showHelp { - if key == "q" || key == "esc" || key == "?" || key == ":help" { + if key == "q" || key == "esc" || key == "?" { m.showHelp = false } return m, nil } - // === Глобальные клавиши === + // Глобальные клавиши if key == m.config.KeyForAction("quit") || key == m.config.KeyForAction("quit_alt") { return m, tea.Quit } - if key == m.config.KeyForAction("toggle_tree") { - m.showFileTree = !m.showFileTree - if m.showFileTree { - m.fileTree.reload() - m.focus = FocusFileTree - m.fileTree.focused = true - m.SetStatusMsg("File tree ON") - } else { - m.focus = FocusEditor - m.fileTree.focused = false - m.SetStatusMsg("File tree OFF") - } - m.clampPanelWidths() - m.updatePreview() - return m, nil - } - - if key == m.config.KeyForAction("focus_switch") { - if m.showFileTree { - if m.focus == FocusEditor { - m.focus = FocusFileTree - m.fileTree.focused = true - m.SetStatusMsg("Focus: file tree") - } else { - m.focus = FocusEditor - m.fileTree.focused = false - m.SetStatusMsg("") - } - } - return m, nil - } - if key == m.config.KeyForAction("panel_shrink") || key == m.config.KeyForAction("panel_grow") { - delta := 2 - if key == m.config.KeyForAction("panel_shrink") { - delta = -2 - } - if m.focus == FocusFileTree && m.showFileTree { - m.resizeTree(delta) - } else if m.showPreview && m.fileType == "md" { + if m.showPreview && m.fileType == "md" { + delta := 2 + if key == m.config.KeyForAction("panel_shrink") { + delta = -2 + } m.resizePreview(delta) + m.updatePreview() } - m.updatePreview() return m, nil } - // === Фокус на дереве === - if m.focus == FocusFileTree { - return m.handleFileTreeKey(msg) - } - - // === Фокус на редакторе === switch m.mode { case NORMAL: return m.handleNormalMode(msg) + case VISUAL: + return m.handleVisualMode(msg) case INSERT: return m.handleInsertMode(msg) case COMMAND: @@ -88,74 +56,110 @@ func (m *EditorModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -// handleFileTreeKey — клавиши при фокусе на дереве -func (m *EditorModel) handleFileTreeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +// handleVisualMode — VISUAL LINE режим (построчное выделение, вход через V). +// Курсор и visualStartY задают диапазон выделенных строк. +// Движение по строкам расширяет/сужает выделение, d/y применяются к диапазону, +// Esc выходит без действия. +func (m *EditorModel) handleVisualMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() - ft := &m.fileTree switch key { + case "esc", "V": + m.mode = NORMAL + m.SetStatusMsg("") + return m, nil + case "j", "down": - ft.moveDown(m.parseNumberPrefix()) + m.moveByLines(m.parseNumberPrefix(), 1) case "k", "up": - ft.moveUp(m.parseNumberPrefix()) + m.moveByLines(m.parseNumberPrefix(), -1) case "g": if m.pendingKeys == "g" { - ft.cursor = 0 - ft.scroll = 0 + m.cursorY = 0 m.pendingKeys = "" } else { m.pendingKeys = "g" } + return m, nil case "G": - ft.cursor = len(ft.nodes) - 1 - ft.adjustScroll(m.height - 3) - m.pendingKeys = "" - case "enter", "l": - if ft.selectedIsDir() { - ft.toggle() - } else if path := ft.selectedPath(); path != "" { - m.openFile(path) - m.focus = FocusEditor - m.fileTree.focused = false + count := m.parseNumberPrefix() + if count == 1 && m.numPrefix == "" { + m.cursorY = len(m.lines) - 1 + } else if count > 0 && count <= len(m.lines) { + m.cursorY = count - 1 } - case "h": - if ft.cursor < len(ft.nodes) { - node := ft.nodes[ft.cursor] - if node.isDir && node.expanded { - ft.toggle() - } else if node.depth > 0 { - for i := ft.cursor - 1; i >= 0; i-- { - if ft.nodes[i].depth < node.depth { - ft.cursor = i - break - } - } - } + + case "d", "x": + start, end := m.visualRange() + m.saveUndoSnapshot() + for i := start; i <= end; i++ { + m.deleteLine(start) } - case "r": - ft.reload() - m.SetStatusMsg("File tree refreshed") - case "esc": - m.focus = FocusEditor - m.fileTree.focused = false - m.SetStatusMsg("") + m.cursorY = start + m.CursorWithinBounds() + m.MarkDirty() + m.mode = NORMAL + n := end - start + 1 + if n == 1 { + m.SetStatusMsg("1 line deleted") + } else { + m.SetStatusMsg(fmt.Sprintf("%d lines deleted", n)) + } + return m, nil + + case "y": + start, end := m.visualRange() + m.clipboard = strings.Join(m.lines[start:end+1], "\n") + m.cursorY = start + m.CursorWithinBounds() + m.mode = NORMAL + n := end - start + 1 + if n == 1 { + m.SetStatusMsg("1 line yanked") + } else { + m.SetStatusMsg(fmt.Sprintf("%d lines yanked", n)) + } + return m, nil + case "1", "2", "3", "4", "5", "6", "7", "8", "9": m.numPrefix += key m.SetStatusMsg(m.numPrefix) + return m, nil case "0": if m.numPrefix != "" { m.numPrefix += key m.SetStatusMsg(m.numPrefix) } + return m, nil } + + m.updateVisualStatus() return m, nil } -// handleNormalMode — NORMAL режим +// visualRange возвращает нормализованный диапазон [start, end] выделенных строк +func (m *EditorModel) visualRange() (start, end int) { + start, end = m.visualStartY, m.cursorY + if start > end { + start, end = end, start + } + return start, end +} + +// updateVisualStatus обновляет статус-строку с количеством выделенных строк +func (m *EditorModel) updateVisualStatus() { + start, end := m.visualRange() + n := end - start + 1 + if n == 1 { + m.SetStatusMsg("1 line selected") + } else { + m.SetStatusMsg(fmt.Sprintf("%d lines selected", n)) + } +} + func (m *EditorModel) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() - // Числовые префиксы if key >= "1" && key <= "9" && m.pendingKeys == "" { m.numPrefix += key m.SetStatusMsg(m.numPrefix) @@ -179,6 +183,11 @@ func (m *EditorModel) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "?": m.showHelp = true return m, nil + case "V": + m.mode = VISUAL + m.visualStartY = m.cursorY + m.SetStatusMsg("") + return m, nil case m.config.KeyForAction("toggle_preview"): if m.fileType == "md" { m.showPreview = !m.showPreview @@ -194,6 +203,15 @@ func (m *EditorModel) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.updatePreview() return m, cmd } + // Операторы d/y/c с motion (dw, de, d$, dd, yw и т.д.) + // должны перехватываться ДО handleMovement, иначе motion + // просто подвинет курсор вместо применения оператора. + if m.pendingKeys == "d" || m.pendingKeys == "y" || m.pendingKeys == "c" { + if m.handleOperatorMotion(key) { + m.updatePreview() + return m, nil + } + } m.handleMovement(key) m.handleEditing(key) m.handleSearchKeys(key) @@ -207,23 +225,22 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd { switch key { case "i": m.mode = INSERT - m.SetStatusMsg("-- INSERT --") + m.SetStatusMsg("") case "a": m.mode = INSERT - // 'a' — после курсора: сдвигаем на одну руну вправо line := []rune(m.lines[m.cursorY]) if m.cursorX < len(line) { m.cursorX++ } - m.SetStatusMsg("-- INSERT --") + m.SetStatusMsg("") case "A": m.mode = INSERT m.cursorX = len([]rune(m.lines[m.cursorY])) - m.SetStatusMsg("-- INSERT --") + m.SetStatusMsg("") case "I": m.mode = INSERT m.cursorX = 0 - m.SetStatusMsg("-- INSERT --") + m.SetStatusMsg("") case "o": m.saveUndoSnapshot() m.insertLine(m.cursorY + 1) @@ -231,14 +248,14 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd { m.cursorX = 0 m.mode = INSERT m.MarkDirty() - m.SetStatusMsg("-- INSERT --") + m.SetStatusMsg("") case "O": m.saveUndoSnapshot() m.insertLine(m.cursorY) m.cursorX = 0 m.mode = INSERT m.MarkDirty() - m.SetStatusMsg("-- INSERT --") + m.SetStatusMsg("") default: return nil } @@ -248,16 +265,14 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd { func (m *EditorModel) handleMovement(key string) { switch key { case "h", "left": - count := m.parseNumberPrefix() - m.cursorX -= count + m.cursorX -= m.parseNumberPrefix() m.CursorWithinBounds() case "j", "down": m.moveByLines(m.parseNumberPrefix(), 1) case "k", "up": m.moveByLines(m.parseNumberPrefix(), -1) case "l", "right": - count := m.parseNumberPrefix() - m.cursorX += count + m.cursorX += m.parseNumberPrefix() m.CursorWithinBounds() case "w": m.moveByWords(m.parseNumberPrefix(), true) @@ -274,9 +289,17 @@ func (m *EditorModel) handleMovement(key string) { case "$": m.numPrefix = "" m.cursorX = len([]rune(m.lines[m.cursorY])) - case "^", "0": + case "0": m.numPrefix = "" m.cursorX = 0 + case "^": + m.numPrefix = "" + line := []rune(m.lines[m.cursorY]) + x := 0 + for x < len(line) && (line[x] == ' ' || line[x] == '\t') { + x++ + } + m.cursorX = x case "g": if m.pendingKeys == "g" { m.cursorY = 0 @@ -312,6 +335,240 @@ func (m *EditorModel) handleMovement(key string) { } } +// handleOperatorMotion обрабатывает d{motion} / y{motion} / c{motion} +// (dw, de, db, d$, d0, d^, dd, yw, ye, yy и т.д.) +// Возвращает true если ключ был обработан как часть operator+motion. +func (m *EditorModel) handleOperatorMotion(key string) bool { + op := m.pendingKeys + count := m.parseNumberPrefix() + + startX, startY := m.cursorX, m.cursorY + line := []rune(m.lines[m.cursorY]) + + switch key { + case "w": + // dw/yw — от курсора до начала следующего слова (исключая его) + endX, endY := startX, startY + for i := 0; i < count; i++ { + endX, endY = m.wordForwardPos(endX, endY) + } + m.applyOperatorRange(op, startX, startY, endX, endY, false) + m.pendingKeys = "" + return true + + case "e": + // de/ye — от курсора до конца текущего/следующего слова (включая последний символ) + endX, endY := startX, startY + for i := 0; i < count; i++ { + endX, endY = m.wordEndPos(endX, endY) + } + m.applyOperatorRange(op, startX, startY, endX, endY, true) + m.pendingKeys = "" + return true + + case "b": + // db/yb — от начала текущего/предыдущего слова до курсора + startX2, startY2 := startX, startY + for i := 0; i < count; i++ { + startX2, startY2 = m.wordBackPos(startX2, startY2) + } + m.applyOperatorRange(op, startX2, startY2, startX, startY, false) + m.pendingKeys = "" + return true + + case "$": + // d$/y$ — от курсора до конца строки (включительно) + endX := len(line) + m.applyOperatorRange(op, startX, startY, endX, startY, false) + m.pendingKeys = "" + return true + + case "0", "^": + // d0/y0 — от начала строки до курсора (исключая текущий символ) + m.applyOperatorRange(op, 0, startY, startX, startY, false) + m.pendingKeys = "" + return true + + case "d": + // dd/yy — вся строка(и) + if op == "d" { + m.saveUndoSnapshot() + for i := 0; i < count; i++ { + m.deleteLine(m.cursorY) + } + m.CursorWithinBounds() + m.MarkDirty() + } else if op == "y" { + end := m.cursorY + count + if end > len(m.lines) { + end = len(m.lines) + } + m.clipboard = strings.Join(m.lines[m.cursorY:end], "\n") + m.SetStatusMsg("Line(s) yanked") + } + m.pendingKeys = "" + return true + + case "y", "c": + // yy уже покрыт выше через "d" case проверки op=="y"; + // здесь второй y для тех кто печатает "yy" — key == "y" совпадает с op == "y" + if key == op { + if op == "y" { + m.clipboard = m.lines[m.cursorY] + m.SetStatusMsg("Line yanked") + } + m.pendingKeys = "" + return true + } + return false + + default: + // Неизвестный motion для оператора — сбрасываем pending, + // чтобы не залипнуть в режиме ожидания + m.pendingKeys = "" + return false + } +} + +// applyOperatorRange применяет delete или yank к диапазону [startX,startY)-(endX,endY) +// inclusive=true означает что символ под endX тоже входит в диапазон (для de/ye) +func (m *EditorModel) applyOperatorRange(op string, startX, startY, endX, endY int, inclusive bool) { + // Нормализуем порядок (motion может быть "назад", как у b) + if startY > endY || (startY == endY && startX > endX) { + startX, endX = endX, startX + startY, endY = endY, startY + } + + if inclusive { + endX++ + } + + if startY == endY { + line := []rune(m.lines[startY]) + if startX < 0 { + startX = 0 + } + if endX > len(line) { + endX = len(line) + } + if startX >= endX { + m.pendingKeys = "" + return + } + text := string(line[startX:endX]) + + if op == "y" { + m.clipboard = text + m.SetStatusMsg("Yanked") + return + } + + // delete (op == "d", change handled same way for now) + m.saveUndoSnapshot() + newLine := append(append([]rune{}, line[:startX]...), line[endX:]...) + m.lines[startY] = string(newLine) + m.cursorX = startX + m.cursorY = startY + m.CursorWithinBounds() + m.MarkDirty() + return + } + + // Многострочный диапазон — пока редкий случай для w/e/b (обычно не выходят за строку), + // но на всякий случай обрабатываем как соединение строк. + firstLine := []rune(m.lines[startY]) + lastLine := []rune(m.lines[endY]) + if startX > len(firstLine) { + startX = len(firstLine) + } + if endX > len(lastLine) { + endX = len(lastLine) + } + + if op == "y" { + var sb strings.Builder + sb.WriteString(string(firstLine[startX:])) + for i := startY + 1; i < endY; i++ { + sb.WriteString("\n" + m.lines[i]) + } + sb.WriteString("\n" + string(lastLine[:endX])) + m.clipboard = sb.String() + m.SetStatusMsg("Yanked") + return + } + + m.saveUndoSnapshot() + merged := string(firstLine[:startX]) + string(lastLine[endX:]) + newLines := append([]string{}, m.lines[:startY]...) + newLines = append(newLines, merged) + newLines = append(newLines, m.lines[endY+1:]...) + m.lines = newLines + m.cursorX = startX + m.cursorY = startY + m.CursorWithinBounds() + m.MarkDirty() +} + +// wordForwardPos возвращает позицию начала следующего слова от (x,y) — логика как у "w" +func (m *EditorModel) wordForwardPos(x, y int) (int, int) { + line := []rune(m.lines[y]) + // Пропускаем текущее слово + for x < len(line) && line[x] != ' ' { + x++ + } + // Пропускаем пробелы + for x < len(line) && line[x] == ' ' { + x++ + } + if x >= len(line) && y < len(m.lines)-1 { + return 0, y + 1 + } + return x, y +} + +// wordEndPos возвращает позицию конца текущего/следующего слова — логика как у "e" +func (m *EditorModel) wordEndPos(x, y int) (int, int) { + line := []rune(m.lines[y]) + if x < len(line) { + x++ + } + for x < len(line) && line[x] == ' ' { + x++ + } + for x < len(line) && line[x] != ' ' { + x++ + } + if x > 0 { + x-- + } + if x >= len(line) && y < len(m.lines)-1 { + return 0, y + 1 + } + return x, y +} + +// wordBackPos возвращает позицию начала текущего/предыдущего слова — логика как у "b" +func (m *EditorModel) wordBackPos(x, y int) (int, int) { + line := []rune(m.lines[y]) + if x > 0 { + x-- + } else if y > 0 { + y-- + line = []rune(m.lines[y]) + x = len(line) + if x > 0 { + x-- + } + } + for x > 0 && line[x] == ' ' { + x-- + } + for x > 0 && line[x-1] != ' ' { + x-- + } + return x, y +} + func (m *EditorModel) handleEditing(key string) { switch key { case "x": @@ -320,24 +577,15 @@ func (m *EditorModel) handleEditing(key string) { m.CursorWithinBounds() m.MarkDirty() case "d": - if m.pendingKeys == "d" { - count := m.parseNumberPrefix() - m.saveUndoSnapshot() - for i := 0; i < count; i++ { - m.deleteLine(m.cursorY) - } - m.CursorWithinBounds() - m.MarkDirty() - m.pendingKeys = "" - } else if m.pendingKeys == "" { + // dd/dw/de/d$ и т.д. обрабатываются в handleOperatorMotion + // (перехватывается раньше handleMovement/handleEditing). + // Сюда попадаем только при первом "d" — устанавливаем pending. + if m.pendingKeys == "" { m.pendingKeys = "d" } case "y": - if m.pendingKeys == "y" { - m.clipboard = m.lines[m.cursorY] - m.SetStatusMsg("Line yanked") - m.pendingKeys = "" - } else if m.pendingKeys == "" { + // yy/yw/ye и т.д. — аналогично, через handleOperatorMotion. + if m.pendingKeys == "" { m.pendingKeys = "y" } case "p": @@ -361,7 +609,6 @@ func (m *EditorModel) handleEditing(key string) { func (m *EditorModel) handleSearchKeys(key string) { if m.pendingKeys == "f" || m.pendingKeys == "F" || m.pendingKeys == "t" || m.pendingKeys == "T" { - // Берём первую руну из нажатой клавиши runes := []rune(key) if len(runes) == 1 { char := runes[0] @@ -380,14 +627,11 @@ func (m *EditorModel) handleSearchKeys(key string) { } } -// handleInsertMode — INSERT режим с полной поддержкой Unicode и навигации func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() - switch key { case "esc": m.mode = NORMAL - // В NORMAL курсор не может стоять после последнего символа line := []rune(m.lines[m.cursorY]) if m.cursorX > 0 && m.cursorX >= len(line) { m.cursorX = len(line) - 1 @@ -396,7 +640,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cursorX = 0 } m.SetStatusMsg("") - case "up": m.moveByLines(1, -1) case "down": @@ -405,7 +648,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cursorX > 0 { m.cursorX-- } else if m.cursorY > 0 { - // Переходим на конец предыдущей строки m.cursorY-- m.cursorX = len([]rune(m.lines[m.cursorY])) } @@ -414,7 +656,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cursorX < len(line) { m.cursorX++ } else if m.cursorY < len(m.lines)-1 { - // Переходим на начало следующей строки m.cursorY++ m.cursorX = 0 } @@ -422,14 +663,12 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cursorX = 0 case "end": m.cursorX = len([]rune(m.lines[m.cursorY])) - case "enter": m.saveUndoSnapshot() m.splitLineRune(m.cursorY, m.cursorX) m.cursorY++ m.cursorX = 0 m.MarkDirty() - case "backspace": m.saveUndoSnapshot() if m.cursorX > 0 { @@ -437,7 +676,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cursorX-- m.MarkDirty() } else if m.cursorY > 0 { - // Merge с предыдущей строкой prevLen := len([]rune(m.lines[m.cursorY-1])) m.lines[m.cursorY-1] += m.lines[m.cursorY] m.lines = append(m.lines[:m.cursorY], m.lines[m.cursorY+1:]...) @@ -445,7 +683,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cursorX = prevLen m.MarkDirty() } - case "delete": m.saveUndoSnapshot() line := []rune(m.lines[m.cursorY]) @@ -453,38 +690,30 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.deleteCharAt(m.cursorY, m.cursorX) m.MarkDirty() } else if m.cursorY < len(m.lines)-1 { - // Merge со следующей строкой m.lines[m.cursorY] += m.lines[m.cursorY+1] m.lines = append(m.lines[:m.cursorY+1], m.lines[m.cursorY+2:]...) m.MarkDirty() } - case "tab": m.saveUndoSnapshot() m.insertRuneAt(m.cursorY, m.cursorX, []rune(strings.Repeat(" ", m.tabWidth))) m.cursorX += m.tabWidth m.MarkDirty() - default: - // Любой печатаемый символ — включая кириллицу и другие Unicode - // Пробел приходит как tea.KeySpace, остальные печатаемые — tea.KeyRunes runes := []rune(msg.String()) - isPrintable := msg.Type == tea.KeyRunes || - msg.Type == tea.KeySpace - if len(runes) == 1 && runes[0] >= 32 && isPrintable { + if len(runes) == 1 && runes[0] >= 32 && + (msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace) { m.saveUndoSnapshot() m.insertRuneAt(m.cursorY, m.cursorX, runes) m.cursorX++ m.MarkDirty() } } - m.CursorWithinBounds() m.updatePreview() return m, nil } -// handleCommandMode — режим команд func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": @@ -503,7 +732,6 @@ func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.SetStatusMsg(":" + m.commandBuffer) default: - // Принимаем Unicode в командном буфере runes := []rune(msg.String()) if len(runes) == 1 && (runes[0] >= 32 || msg.Type == tea.KeyRunes) { m.commandBuffer += string(runes) @@ -513,7 +741,6 @@ func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -// handleSearchMode — режим поиска func (m *EditorModel) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": @@ -564,9 +791,8 @@ func (m *EditorModel) movePrevWord() { } } -// === Редактирование (rune-safe) === +// === Редактирование === -// insertRuneAt вставляет руны в строку на позиции pos (в рунах) func (m *EditorModel) insertRuneAt(lineIdx, pos int, runes []rune) { line := []rune(m.lines[lineIdx]) if pos < 0 { @@ -582,7 +808,6 @@ func (m *EditorModel) insertRuneAt(lineIdx, pos int, runes []rune) { m.lines[lineIdx] = string(newLine) } -// deleteCharAt удаляет символ на позиции pos (в рунах) func (m *EditorModel) deleteCharAt(lineIdx, pos int) { line := []rune(m.lines[lineIdx]) if pos < 0 || pos >= len(line) { @@ -591,7 +816,6 @@ func (m *EditorModel) deleteCharAt(lineIdx, pos int) { m.lines[lineIdx] = string(append(line[:pos], line[pos+1:]...)) } -// splitLineRune разбивает строку на позиции pos (в рунах) func (m *EditorModel) splitLineRune(lineIdx, pos int) { line := []rune(m.lines[lineIdx]) if pos < 0 { @@ -636,4 +860,4 @@ func (m *EditorModel) deleteLine(idx int) { } else { m.lines = []string{""} } -} +} \ No newline at end of file diff --git a/main.go b/main.go old mode 100644 new mode 100755 index cc7e407..cc02e08 --- a/main.go +++ b/main.go @@ -80,7 +80,10 @@ func main() { return case menuNewFile: - filePath = "untitled.md" + // Гарантированно пустой буфер — даже если untitled.md уже существует + // на диске от прошлой сессии, не подгружаем его содержимое. + runNewEditor(cfg) + return case menuOpenFile: filePath = result.FilePath @@ -110,6 +113,20 @@ func main() { runEditor(filePath, cfg, false) } +// runNewEditor запускает редактор с гарантированно пустым буфером, +// не читая существующий файл с диска (используется для "Новый файл" в меню). +// Имя файла остаётся "untitled.md" — пользователь сохранит его явным :w/:saveas, +// что не перезапишет существующий файл, так как save() запросит подтверждение +// только при явном указании того же имени. +func runNewEditor(cfg Config) { + model := NewEditor("untitled.md", "md", "", cfg) + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + // runEditor загружает файл и запускает редактор. // showHelp=true открывает справку сразу при старте. func runEditor(filePath string, cfg Config, showHelp bool) { @@ -130,4 +147,4 @@ func runEditor(filePath string, cfg Config, showHelp bool) { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } -} +} \ No newline at end of file diff --git a/menu.go b/menu.go old mode 100644 new mode 100755 index 00c14aa..3af3555 --- a/menu.go +++ b/menu.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -60,6 +61,13 @@ type menuModel struct { input string // буфер для ввода пути/URL inputMode string // "" / "file" / "url" err string // сообщение об ошибке + + // Детектор аномально быстрого ввода (вставка большого текста посимвольно). + // Терминал может разбивать paste на отдельные KeyMsg без bracketed-paste + // маркеров — тогда обычная защита по длине срабатывает слишком поздно, + // так как Ctrl+C встаёт в конец уже накопленной очереди событий. + lastKeyTime time.Time + rapidKeyCount int } // doneMsg сигнализирует о завершении меню @@ -131,11 +139,15 @@ func (m menuModel) selectItem(action menuAction) (tea.Model, tea.Cmd) { m.inputMode = "file" m.input = "" m.err = "" + m.rapidKeyCount = 0 + m.lastKeyTime = time.Time{} return m, nil case menuFetchURL: m.inputMode = "url" m.input = "" m.err = "" + m.rapidKeyCount = 0 + m.lastKeyTime = time.Time{} return m, nil case menuHelp: return m, func() tea.Msg { return menuDoneMsg{MenuResult{Action: menuHelp}} } @@ -144,11 +156,40 @@ func (m menuModel) selectItem(action menuAction) (tea.Model, tea.Cmd) { } func (m menuModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Детектор вставки большого текста посимвольно: если терминал не + // поддерживает bracketed paste, каждый символ из paste приходит как + // отдельный KeyMsg практически без задержки между ними (быстрее чем + // способен печатать человек — порог 8мс). Считаем подряд идущие + // быстрые нажатия и при превышении порога обрываем ввод немедленно, + // не дожидаясь пока вся вставка "проедется" через очередь событий. + now := time.Now() + if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { + if !m.lastKeyTime.IsZero() && now.Sub(m.lastKeyTime) < 8*time.Millisecond { + m.rapidKeyCount++ + } else { + m.rapidKeyCount = 0 + } + m.lastKeyTime = now + + const rapidThreshold = 20 + if m.rapidKeyCount >= rapidThreshold { + m.input = "" + m.rapidKeyCount = 0 + m.err = "Обнаружена вставка текста — поле очищено, введите URL вручную" + return m, nil + } + } + switch msg.String() { + case "ctrl+c": + // Гарантированный выход даже если что-то пошло не так с вводом + // (например случайная вставка большого текста) + return m, tea.Quit case "esc": m.inputMode = "" m.input = "" m.err = "" + m.rapidKeyCount = 0 case "enter": if m.input == "" { m.err = "Введите значение" @@ -173,7 +214,13 @@ func (m menuModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.err = "" default: if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { - m.input += msg.String() + // Лимит защищает от случайной вставки большого текста + // (например содержимого файла через paste), которая иначе + // засоряет поле ввода и делает интерфейс нечитаемым. + const maxInputLen = 500 + if len([]rune(m.input)) < maxInputLen { + m.input += msg.String() + } m.err = "" } } @@ -291,7 +338,20 @@ func (m menuModel) View() string { prompt = "URL статьи: " } - promptLine := promptStyle.Render(prompt) + inputStyle.Render(m.input+"▌") + // Обрезаем отображаемую часть ввода чтобы не ломать вёрстку, + // если что-то вставило длинный текст. Показываем хвост — + // он ближе к курсору и обычно важнее для проверки. + displayInput := m.input + maxVisible := m.width - visibleWidth(prompt) - 10 + if maxVisible < 10 { + maxVisible = 10 + } + inputRunes := []rune(displayInput) + if len(inputRunes) > maxVisible { + displayInput = "…" + string(inputRunes[len(inputRunes)-maxVisible+1:]) + } + + promptLine := promptStyle.Render(prompt) + inputStyle.Render(displayInput+"▌") promptVis := visibleWidth(promptLine) promptPad := (m.width - promptVis) / 2 if promptPad < 0 { @@ -310,7 +370,17 @@ func (m menuModel) View() string { } out.WriteString(strings.Repeat(" ", errPad)) out.WriteString(errLine) + out.WriteString("\n") } + + inputHint := dimStyle.Render("Enter подтвердить Esc отмена Ctrl+C выход") + inputHintVis := visibleWidth(inputHint) + inputHintPad := (m.width - inputHintVis) / 2 + if inputHintPad < 0 { + inputHintPad = 0 + } + out.WriteString(strings.Repeat(" ", inputHintPad)) + out.WriteString(inputHint) out.WriteString("\n") } else { hint := dimStyle.Render("↑↓ навигация Enter выбор q выйти") @@ -344,4 +414,4 @@ func RunMenu() *MenuResult { return fm.result } return nil -} +} \ No newline at end of file diff --git a/model.go b/model.go old mode 100644 new mode 100755 index 7b72039..374ff6e --- a/model.go +++ b/model.go @@ -1,7 +1,6 @@ package main import ( - "path/filepath" "strings" "time" @@ -20,14 +19,6 @@ const ( REPLACE ) -// PanelFocus — какая панель принимает клавиши -type PanelFocus int - -const ( - FocusEditor PanelFocus = iota - FocusFileTree -) - // undoSnapshot — снимок состояния для undo/redo type undoSnapshot struct { lines []string @@ -35,12 +26,8 @@ type undoSnapshot struct { cursorY int } -// Минимальные ширины панелей -const ( - minEditorWidth = 20 - minTreeWidth = 15 - minPreviewWidth = 20 -) +// Минимальная ширина панели preview +const minPreviewWidth = 20 type EditorModel struct { // Контент @@ -49,12 +36,9 @@ type EditorModel struct { cursorY int // Vim режимы - mode VimMode - pendingKeys string - selection struct { - startX, startY int - endX, endY int - } + mode VimMode + pendingKeys string + visualStartY int // строка где начато VISUAL LINE выделение // Файл filepath string @@ -63,28 +47,24 @@ type EditorModel struct { lastSave time.Time statusMsg string - // UI — размеры + // UI width int height int - treeWidth int previewWidth int // Видимость панелей - showFileTree bool - showPreview bool + showPreview bool - // Фокус - focus PanelFocus - - // Настройки (из конфига или :set) + // Настройки showLineNumbers bool tabWidth int - autosaveInterval int // секунды - cfgShowPreview bool // дефолт для preview из конфига + autosaveInterval int + cfgShowPreview bool // Viewport - viewport viewport.Model - previewBuf string + viewport viewport.Model + previewBuf string + previewDirty bool // Команда commandBuffer string @@ -111,31 +91,27 @@ type EditorModel struct { undoStack []undoSnapshot redoStack []undoSnapshot - // Файловое дерево - fileTree FileTree - // Help экран showHelp bool - // Конфиг (хранится для :config show) + // Пасхалка + showTzeentch bool + tzeentchIdx int + + // Конфиг config Config } // Типы событий type TickMsg time.Time -// NewEditor создаёт новый редактор с применением конфига +// 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, @@ -148,27 +124,14 @@ func NewEditor(path, fileType, content string, cfg Config) *EditorModel { tabWidth: cfg.TabWidth, autosaveInterval: cfg.AutosaveInterval, cfgShowPreview: cfg.ShowPreview, - showPreview: cfg.ShowPreview && fileType == "md" || fileType == "md" && cfg.ShowPreview, - showFileTree: cfg.ShowTree, - focus: FocusEditor, + showPreview: cfg.ShowPreview && fileType == "md", lastSave: time.Now(), viewport: viewport.New(0, 0), width: 120, height: 30, - treeWidth: cfg.TreeWidth, previewWidth: cfg.PreviewWidth, + previewDirty: true, 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() @@ -176,7 +139,6 @@ func NewEditor(path, fileType, content string, cfg Config) *EditorModel { return m } -// Init инициализирует модель func (m *EditorModel) Init() tea.Cmd { return m.tickCmd() } @@ -184,14 +146,13 @@ func (m *EditorModel) Init() tea.Cmd { func (m *EditorModel) tickCmd() tea.Cmd { interval := time.Duration(m.autosaveInterval) * time.Second if interval <= 0 { - interval = 60 * time.Second // fallback + interval = 60 * time.Second } 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: @@ -203,7 +164,31 @@ func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: - return m.handleKey(msg) + // 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 && @@ -217,84 +202,40 @@ func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// View возвращает строку для вывода func (m *EditorModel) View() string { + if m.showTzeentch { + return renderTzeentch(m) + } 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() + if m.showPreview && m.fileType == "md" { + return renderSplit(m) } + return renderEditor(m) } -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 + if w < 20 { + w = 20 } 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 + needed := 20 + m.previewWidth + if needed > m.width && m.width > 0 { + m.previewWidth = m.width - 20 if m.previewWidth < minPreviewWidth { m.previewWidth = minPreviewWidth } @@ -302,35 +243,18 @@ func (m *EditorModel) clampPanelWidths() { } } -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 + max := m.width - 20 + if m.previewWidth > max { + m.previewWidth = max } } -// === Открытие файла из дерева === +// === Открытие файла === func (m *EditorModel) openFile(path string) { content, err := LoadFile(path) @@ -357,6 +281,7 @@ func (m *EditorModel) openFile(path string) { m.numPrefix = "" m.pendingKeys = "" m.showPreview = fileType == "md" && m.cfgShowPreview + m.previewDirty = true m.mode = NORMAL m.clampPanelWidths() m.SetStatusMsg("Opened: " + path) @@ -366,10 +291,13 @@ func (m *EditorModel) openFile(path string) { // === Вспомогательные методы === 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) MarkDirty() { + m.dirty = true + m.previewDirty = true + InvalidateHighlightCache() +} func (m *EditorModel) CursorWithinBounds() { if m.cursorY < 0 { @@ -379,7 +307,6 @@ func (m *EditorModel) CursorWithinBounds() { m.cursorY = len(m.lines) - 1 } maxX := len([]rune(m.lines[m.cursorY])) - // В NORMAL режиме курсор не может стоять за последним символом if m.mode != INSERT && maxX > 0 { maxX-- } @@ -393,16 +320,30 @@ func (m *EditorModel) CursorWithinBounds() { // === 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() { - snapshot := undoSnapshot{ + snap := 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:] + 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 } @@ -422,12 +363,13 @@ func (m *EditorModel) Undo() { 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.lines = snap.lines m.cursorX = snap.cursorX m.cursorY = snap.cursorY m.CursorWithinBounds() m.dirty = true + m.previewDirty = true + InvalidateHighlightCache() m.SetStatusMsg("Undo") } @@ -447,11 +389,12 @@ func (m *EditorModel) Redo() { copy(undoSnap.lines, m.lines) m.undoStack = append(m.undoStack, undoSnap) - m.lines = make([]string, len(snap.lines)) - copy(m.lines, snap.lines) + m.lines = snap.lines m.cursorX = snap.cursorX m.cursorY = snap.cursorY m.CursorWithinBounds() m.dirty = true + m.previewDirty = true + InvalidateHighlightCache() m.SetStatusMsg("Redo") -} +} \ No newline at end of file diff --git a/pdf.go b/pdf.go old mode 100644 new mode 100755 diff --git a/pdf_font.go b/pdf_font.go old mode 100644 new mode 100755 diff --git a/render.go b/render.go old mode 100644 new mode 100755 index 4cbd0bb..aa3cd51 --- a/render.go +++ b/render.go @@ -4,11 +4,21 @@ import ( "fmt" "regexp" "strings" + "sync" "github.com/charmbracelet/lipgloss" "github.com/mattn/go-runewidth" ) +// === Кеш visibleWidth === +// Большинство строк рендерятся повторно без изменений — кешируем результат. + +var ( + vwCache = map[string]int{} + vwCacheMu sync.RWMutex + vwCacheMax = 4000 +) + // === UI стили === var ( colorBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) @@ -23,7 +33,7 @@ var ( colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) ) -const grimVersion = "grimoir v0.4" +const grimVersion = "grimoir v1.0" // === Главная точка рендера === @@ -40,21 +50,7 @@ func renderSplit(m *EditorModel) string { return joinRowColumns([][]string{edRows, prRows}, []int{edW, prW}, m.height) } -func renderTreeEditor(m *EditorModel) string { - edW := m.editorWidth() - trRows := buildTreeRows(m, m.treeWidth, m.height) - edRows := buildEditorRows(m, edW, m.height) - return joinRowColumns([][]string{trRows, edRows}, []int{m.treeWidth, edW}, m.height) -} -func renderTreeEditorPreview(m *EditorModel) string { - edW := m.editorWidth() - prW := m.previewWidth - trRows := buildTreeRows(m, m.treeWidth, m.height) - edRows := buildEditorRows(m, edW, m.height) - prRows := buildPreviewRows(m, prW, m.height) - return joinRowColumns([][]string{trRows, edRows, prRows}, []int{m.treeWidth, edW, prW}, m.height) -} // === Построители строк (возвращают []string, по одной на строку экрана) === @@ -78,6 +74,12 @@ func buildEditorRows(m *EditorModel, width, height int) []string { contentW = 1 } + // Диапазон выделения для VISUAL LINE режима (если активен) + var visStart, visEnd int = -1, -1 + if m.mode == VISUAL { + visStart, visEnd = m.visualRange() + } + for lineNum := start; lineNum < end; lineNum++ { var sb strings.Builder if m.showLineNumbers { @@ -93,7 +95,12 @@ func buildEditorRows(m *EditorModel, width, height int) []string { rendered = renderLine(line, m.fileType) } // Обрезаем по ширине контента - sb.WriteString(truncateVisible(rendered, contentW)) + truncated := truncateVisible(rendered, contentW) + // Подсветка фона для выделенных строк VISUAL LINE + if visStart >= 0 && lineNum >= visStart && lineNum <= visEnd { + truncated = visualHighlight(truncated, contentW) + } + sb.WriteString(truncated) rows = append(rows, sb.String()) } @@ -108,55 +115,7 @@ func buildEditorRows(m *EditorModel, width, height int) []string { return rows } -// buildTreeRows строит строки файлового дерева -func buildTreeRows(m *EditorModel, width, height int) []string { - ft := &m.fileTree - rows := make([]string, 0, height) - // Заголовок - title := truncateString(filepath_base(ft.root), width-4) - bgColor := "8" - if ft.focused { - bgColor = "4" - } - headerContent := " > " + title - rows = append(rows, ansiLine( - "\x1b[48;5;"+bgColor+"m\x1b[38;5;15m\x1b[1m"+headerContent+"\x1b[0m", - width, - )) - - listHeight := height - 2 // заголовок + подсказка - ft.adjustScroll(listHeight) - - shown := 0 - for i := ft.scroll; i < ft.scroll+listHeight && i < len(ft.nodes); i++ { - node := ft.nodes[i] - line := buildFileNodeLine(node, m.filepath) - - if i == ft.cursor && ft.focused { - // Выделенный элемент — инвертируем фон - line = "\x1b[48;5;237m\x1b[38;5;15m\x1b[1m" + stripANSI(line) + "\x1b[0m" - } else if i == ft.cursor { - line = "\x1b[38;5;245m" + stripANSI(line) + "\x1b[0m" - } - - rows = append(rows, line) - shown++ - } - - // Пустые строки - for shown < listHeight { - rows = append(rows, "") - shown++ - } - - // Подсказка - hint := " Enter:open Tab:focus :width" - hint = truncateString(hint, width) - rows = append(rows, "\x1b[38;5;8m"+hint+"\x1b[0m") - - return rows -} // buildPreviewRows строит строки preview func buildPreviewRows(m *EditorModel, width, height int) []string { @@ -188,6 +147,7 @@ func buildPreviewRows(m *EditorModel, width, height int) []string { func joinRowColumns(cols [][]string, widths []int, height int) string { var out strings.Builder for row := 0; row < height; row++ { + out.WriteString(clearLine) for ci, col := range cols { w := widths[ci] line := "" @@ -210,7 +170,11 @@ func joinRowColumns(cols [][]string, widths []int, height int) string { } func rowsToString(rows []string) string { - return strings.Join(rows, "\n") + "\n" + withClear := make([]string, len(rows)) + for i, r := range rows { + withClear[i] = clearLine + r + } + return strings.Join(withClear, "\n") + "\n" } // === Построители отдельных элементов === @@ -252,9 +216,6 @@ func buildStatusBar(m *EditorModel, width int) string { } hints := []string{m.fileType} - if m.showFileTree { - hints = append(hints, fmt.Sprintf("tree:%d", m.treeWidth)) - } if m.showPreview && m.fileType == "md" { hints = append(hints, fmt.Sprintf("prev:%d", m.previewWidth)) } @@ -272,25 +233,6 @@ func buildStatusBar(m *EditorModel, width int) string { "\x1b[38;5;11m" + right + "\x1b[0m" } -func buildFileNodeLine(node *FileNode, currentFile string) string { - indent := "" - if node.depth > 0 { - indent = strings.Repeat(" ", node.depth-1) - } - - if node.isDir { - icon := "▸ " - if node.expanded { - icon = "▾ " - } - return indent + "\x1b[1;38;5;33m" + icon + node.name + "\x1b[0m" - } - - if node.path == currentFile { - return indent + " \x1b[38;5;214m● " + node.name + "\x1b[0m" - } - return indent + " \x1b[38;5;252m" + node.name + "\x1b[0m" -} // === Рендер строк текста === @@ -333,7 +275,20 @@ func renderLineWithCursor(line string, cursorX int, fileType string) string { // visibleWidth возвращает визуальную ширину строки в терминале, // учитывая ANSI escape-коды и double-width символы через go-runewidth. +// Результат кешируется — большинство строк повторяются между кадрами. func visibleWidth(s string) int { + // Быстрый путь для коротких строк без ANSI + if len(s) == 0 { + return 0 + } + + vwCacheMu.RLock() + if v, ok := vwCache[s]; ok { + vwCacheMu.RUnlock() + return v + } + vwCacheMu.RUnlock() + w := 0 inEsc := false for _, r := range s { @@ -349,9 +304,23 @@ func visibleWidth(s string) int { } w += runewidth.RuneWidth(r) } + + vwCacheMu.Lock() + if len(vwCache) < vwCacheMax { + vwCache[s] = w + } + vwCacheMu.Unlock() + return w } +// InvalidateVWCache сбрасывает кеш visibleWidth +func InvalidateVWCache() { + vwCacheMu.Lock() + vwCache = map[string]int{} + vwCacheMu.Unlock() +} + // stripANSI удаляет все ANSI escape-последовательности func stripANSI(s string) string { var sb strings.Builder @@ -373,6 +342,31 @@ func stripANSI(s string) string { } // truncateVisible обрезает строку до maxWidth видимых столбцов терминала +// visualHighlight оборачивает строку в фон VISUAL LINE выделения и +// добивает её пробелами с тем же фоном до полной ширины колонки, +// чтобы подсветка визуально покрывала всю строку, а не только текст. +func visualHighlight(s string, width int) string { + vis := visibleWidth(s) + pad := width - vis + if pad < 0 { + pad = 0 + } + return "\x1b[48;5;24m" + s + "\x1b[0m\x1b[48;5;24m" + strings.Repeat(" ", pad) + "\x1b[0m" +} + +// truncateVisible обрезает строку до maxWidth видимых столбцов терминала. +// ВСЕГДА возвращает строку ровно maxWidth видимой ширины — добивает пробелами +// если короче. Это критично: если просто обрезать без добивки, при замене +// длинной строки на более короткую между кадрами (например при скролле) +// "хвост" предыдущей строки остаётся не стёртым в терминальном буфере, +// что выглядит как дублирование/застревание старого контента. +// clearLine — ANSI escape для полной очистки текущей строки терминала. +// Используется как защита от artifacts в терминальных мультиплексорах +// (Zellij, tmux), которые могут кешировать содержимое строки построчно +// и не полностью перерисовывать её, если новая строка короче предыдущей +// даже при наличии добивки пробелами с другим цветом фона. +const clearLine = "\x1b[2K" + func truncateVisible(s string, maxWidth int) string { if maxWidth <= 0 { return "" @@ -401,6 +395,11 @@ func truncateVisible(s string, maxWidth int) string { vis += rw } sb.WriteString("\x1b[0m") + // Добиваем пробелами до точной ширины — без этого короткие строки + // не перекрывают остатки предыдущего, более длинного контента. + if vis < maxWidth { + sb.WriteString(strings.Repeat(" ", maxWidth-vis)) + } return sb.String() } @@ -500,7 +499,12 @@ func (m *EditorModel) updatePreview() { m.previewBuf = "" return } + // Пересчитываем только если контент изменился + if !m.previewDirty { + return + } m.previewBuf = renderMarkdown(m.lines) + m.previewDirty = false } func renderMarkdown(lines []string) string { @@ -642,4 +646,4 @@ func max(a, b int) int { return a } return b -} +} \ No newline at end of file diff --git a/search.go b/search.go old mode 100644 new mode 100755 index 4defc13..d5cbdd1 --- a/search.go +++ b/search.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" ) @@ -82,6 +83,10 @@ func (m *EditorModel) startSearch() { m.SetStatusMsg("/") } +// maxSearchMatches — максимум совпадений при поиске +// Предотвращает зависание на файлах с сотнями тысяч строк +const maxSearchMatches = 1000 + func (m *EditorModel) performSearch(pattern string) { m.lastSearchTerm = pattern m.searchMatches = nil @@ -91,10 +96,11 @@ func (m *EditorModel) performSearch(pattern string) { return } + patRunes := []rune(pattern) + truncated := false + for lineNum, line := range m.lines { - // Ищем в рунах чтобы позиции были корректными lineRunes := []rune(line) - patRunes := []rune(pattern) for i := 0; i <= len(lineRunes)-len(patRunes); i++ { match := true for j, r := range patRunes { @@ -105,8 +111,15 @@ func (m *EditorModel) performSearch(pattern string) { } if match { m.searchMatches = append(m.searchMatches, struct{ line, col int }{lineNum, i}) + if len(m.searchMatches) >= maxSearchMatches { + truncated = true + break + } } } + if truncated { + break + } } if len(m.searchMatches) > 0 { @@ -114,7 +127,12 @@ func (m *EditorModel) performSearch(pattern string) { m.cursorY = match.line m.cursorX = match.col m.searchIdx = 0 - m.SetStatusMsg("Found: " + pattern) + if truncated { + m.SetStatusMsg(fmt.Sprintf("Found %d+ matches (showing first %d): %s", + maxSearchMatches, maxSearchMatches, pattern)) + } else { + m.SetStatusMsg(fmt.Sprintf("Found %d match(es): %s", len(m.searchMatches), pattern)) + } } else { m.SetStatusMsg("Pattern not found: " + pattern) } @@ -281,4 +299,4 @@ func (m *EditorModel) findMatchingBracket() { } } m.SetStatusMsg("No matching bracket") -} +} \ No newline at end of file diff --git a/syntax.go b/syntax.go old mode 100644 new mode 100755 index 9c7e88b..862d714 --- a/syntax.go +++ b/syntax.go @@ -3,6 +3,7 @@ package main import ( "regexp" "strings" + "sync" "github.com/charmbracelet/lipgloss" ) @@ -91,16 +92,134 @@ var languageConfigs = map[string]LanguageConfig{ }, } +// === Кеш скомпилированных regexp === + +// compiledLangPatterns — скомпилированные паттерны для одного языка +type compiledLangPatterns struct { + strings []*regexp.Regexp // по одному на каждый StringDelimiter + keywords []*regexp.Regexp // по одному на каждое ключевое слово + numbers *regexp.Regexp // nil если !HasNumbers + varPattern *regexp.Regexp // nil если VarPrefix == "" +} + +var ( + patternCache = map[string]*compiledLangPatterns{} + patternCacheMu sync.RWMutex +) + +// getPatterns возвращает скомпилированные паттерны для языка (из кеша или компилирует) +func getPatterns(fileType string, cfg LanguageConfig) *compiledLangPatterns { + patternCacheMu.RLock() + if p, ok := patternCache[fileType]; ok { + patternCacheMu.RUnlock() + return p + } + patternCacheMu.RUnlock() + + // Компилируем один раз + p := &compiledLangPatterns{} + + for _, delim := range cfg.StringDelimiters { + var re *regexp.Regexp + switch delim { + case `"`: + re = regexp.MustCompile(`"(?:[^"\\]|\\.)*"`) + case `'`: + re = regexp.MustCompile(`'(?:[^'\\]|\\.)*'`) + case "`": + re = regexp.MustCompile("`[^`]*`") + } + p.strings = append(p.strings, re) + } + + for _, kw := range cfg.Keywords { + var re *regexp.Regexp + if cfg.CaseInsensitive { + re = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(kw) + `\b`) + } else { + re = regexp.MustCompile(`\b` + regexp.QuoteMeta(kw) + `\b`) + } + p.keywords = append(p.keywords, re) + } + + if cfg.HasNumbers { + p.numbers = regexp.MustCompile(`\b\d+(\.\d+)?\b`) + } + + if cfg.VarPrefix != "" { + p.varPattern = regexp.MustCompile(regexp.QuoteMeta(cfg.VarPrefix) + `\w+`) + } + + patternCacheMu.Lock() + patternCache[fileType] = p + patternCacheMu.Unlock() + + return p +} + +// === Кеш подсвеченных строк === + +// highlightCacheKey — ключ кеша подсветки +type highlightCacheKey struct { + line string + fileType string +} + +var ( + highlightCache = map[highlightCacheKey]string{} + highlightCacheMu sync.RWMutex + // Ограничиваем размер кеша — не кешируем больше N строк + highlightCacheMax = 2000 +) + +// HighlightLine подсвечивает одну строку кода +func HighlightLine(line, fileType string) string { + cfg, ok := languageConfigs[fileType] + if !ok { + return line + } + + // Проверяем кеш + key := highlightCacheKey{line, fileType} + highlightCacheMu.RLock() + if cached, ok := highlightCache[key]; ok { + highlightCacheMu.RUnlock() + return cached + } + highlightCacheMu.RUnlock() + + // Вычисляем + patterns := getPatterns(fileType, cfg) + tokens := tokenizeWithPatterns(line, cfg, patterns) + result := renderTokens(tokens, cfg) + + // Сохраняем в кеш + highlightCacheMu.Lock() + if len(highlightCache) < highlightCacheMax { + highlightCache[key] = result + } + highlightCacheMu.Unlock() + + return result +} + +// InvalidateHighlightCache сбрасывает кеш подсветки (вызывается при изменении файла) +func InvalidateHighlightCache() { + highlightCacheMu.Lock() + highlightCache = map[highlightCacheKey]string{} + highlightCacheMu.Unlock() +} + // tokenKind — тип токена type tokenKind int const ( tokenPlain tokenKind = iota - tokenKeyword // ключевое слово - tokenString // строковый литерал - tokenComment // комментарий - tokenNumber // число - tokenVar // переменная ($VAR) + tokenKeyword + tokenString + tokenComment + tokenNumber + tokenVar ) // token — фрагмент строки с типом @@ -109,23 +228,13 @@ type token struct { kind tokenKind } -// HighlightLine подсвечивает одну строку кода -func HighlightLine(line, fileType string) string { - cfg, ok := languageConfigs[fileType] - if !ok { - return line - } - tokens := tokenize(line, cfg) - return renderTokens(tokens, cfg) -} - -// tokenize разбивает строку на токены не применяя стили -func tokenize(line string, cfg LanguageConfig) []token { +// tokenizeWithPatterns разбивает строку на токены используя предкомпилированные regexp +func tokenizeWithPatterns(line string, cfg LanguageConfig, p *compiledLangPatterns) []token { if line == "" { return nil } - // Шаг 1: находим комментарий — всё после него plain (красим как comment целиком) + // Находим комментарий commentStart := -1 if cfg.LineCommentPrefix != "" { commentStart = strings.Index(line, cfg.LineCommentPrefix) @@ -138,27 +247,15 @@ func tokenize(line string, cfg LanguageConfig) []token { comment = line[commentStart:] } - // Шаг 2: разбиваем code на токены через span-маппинг - // Каждая позиция байта получает тип; потом склеиваем в токены n := len(code) kinds := make([]tokenKind, n) - // Строковые литералы - for _, delim := range cfg.StringDelimiters { - var re *regexp.Regexp - switch delim { - case `"`: - re = regexp.MustCompile(`"(?:[^"\\]|\\.)*"`) - case `'`: - re = regexp.MustCompile(`'(?:[^'\\]|\\.)*'`) - case "`": - re = regexp.MustCompile("`[^`]*`") - } + // Строки + for _, re := range p.strings { if re == nil { continue } for _, loc := range re.FindAllStringIndex(code, -1) { - // Только если это место ещё не занято already := false for i := loc[0]; i < loc[1]; i++ { if kinds[i] != tokenPlain { @@ -175,15 +272,8 @@ func tokenize(line string, cfg LanguageConfig) []token { } // Ключевые слова - for _, kw := range cfg.Keywords { - var re *regexp.Regexp - if cfg.CaseInsensitive { - re = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(kw) + `\b`) - } else { - re = regexp.MustCompile(`\b` + regexp.QuoteMeta(kw) + `\b`) - } + for _, re := range p.keywords { for _, loc := range re.FindAllStringIndex(code, -1) { - // Не перекрываем строки inString := false for i := loc[0]; i < loc[1]; i++ { if kinds[i] == tokenString { @@ -200,45 +290,33 @@ func tokenize(line string, cfg LanguageConfig) []token { } // Числа - if cfg.HasNumbers { - re := regexp.MustCompile(`\b\d+(\.\d+)?\b`) - for _, loc := range re.FindAllStringIndex(code, -1) { - inString := false + if p.numbers != nil { + for _, loc := range p.numbers.FindAllStringIndex(code, -1) { + skip := false for i := loc[0]; i < loc[1]; i++ { - if kinds[i] == tokenString { - inString = true + if kinds[i] != tokenPlain { + skip = true break } } - if !inString { - // Не красим если уже keyword - isKW := false + if !skip { for i := loc[0]; i < loc[1]; i++ { - if kinds[i] == tokenKeyword { - isKW = true - break - } - } - if !isKW { - for i := loc[0]; i < loc[1]; i++ { - kinds[i] = tokenNumber - } + kinds[i] = tokenNumber } } } } - // Переменные ($VAR) - if cfg.VarPrefix != "" { - re := regexp.MustCompile(regexp.QuoteMeta(cfg.VarPrefix) + `\w+`) - for _, loc := range re.FindAllStringIndex(code, -1) { + // Переменные + if p.varPattern != nil { + for _, loc := range p.varPattern.FindAllStringIndex(code, -1) { for i := loc[0]; i < loc[1]; i++ { kinds[i] = tokenVar } } } - // Шаг 3: склеиваем соседние байты одного типа в токены + // Склеиваем в токены var tokens []token i := 0 for i < n { @@ -251,7 +329,6 @@ func tokenize(line string, cfg LanguageConfig) []token { i = j } - // Комментарий добавляем в конец if comment != "" { tokens = append(tokens, token{text: comment, kind: tokenComment}) } @@ -283,4 +360,4 @@ func renderTokens(tokens []token, cfg LanguageConfig) string { } } return sb.String() -} +} \ No newline at end of file diff --git a/tzeentch.go b/tzeentch.go new file mode 100644 index 0000000..e1bf799 --- /dev/null +++ b/tzeentch.go @@ -0,0 +1,269 @@ +package main + +import ( + "math/rand" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// === Пасхалка: Just as planned === + +var tzeentchQuotes = []struct { + ru string + en string + source string +}{ + { + ru: `Тзинч — это перемены. Тзинч — это надежда. +Тзинч — это движение вперёд, к лучшему будущему, +которое всегда маячит впереди, всегда достижимо, +но никогда не достигнуто. Ибо достижение цели — +это конец перемен, а конец перемен — это смерть.`, + en: `Tzeentch is change. Tzeentch is hope. +Tzeentch is the drive forward, toward a better future +that always beckons, always within reach, +yet never grasped. For to reach one's goal +is the end of change, and the end of change is death.`, + source: "Книга Тзинча / The Book of Tzeentch", + }, + { + ru: `Всё свершается по его плану. Каждое предательство, +каждый союз, каждая победа и каждое поражение — +лишь фигуры на доске Изменника Бога. +Вы думаете, что действуете по своей воле? +Именно этого он и хотел, чтобы вы думали.`, + en: `All unfolds according to his plan. Every betrayal, +every alliance, every victory and every defeat — +mere pieces on the Changer of Ways' board. +You believe you act of your own free will? +That is precisely what he wanted you to think.`, + source: "Еретические письмена, авт. неизв. / Heretical Writings, author unknown", + }, + { + ru: `Говорят, что Тзинч знает каждую нить судьбы +каждого существа во Вселенной. +Говорят, что он плетёт их по своему усмотрению. +Говорят многое. +Тзинч позаботился об этом.`, + en: `They say Tzeentch knows every thread of fate +of every being in the Universe. +They say he weaves them as he sees fit. +They say many things. +Tzeentch made sure of that.`, + source: "Apocrypha of Amon Sul", + }, + { + ru: `Мы — его пешки. Его рыцари. Его ладьи. +Но никто из нас не знает своей роли на доске. +Только он видит партию целиком. +И он никогда не проигрывает. +Ибо проигрыш — тоже часть плана.`, + en: `We are his pawns. His knights. His rooks. +But none of us knows our role upon the board. +Only he sees the game entire. +And he never loses. +For defeat, too, is part of the plan.`, + source: "Из признаний чародея Эгримм Вар Гольда / Confessions of Sorcerer Egrimm van Horstmann", + }, + { + ru: `Судьба — не нить, которую можно оборвать. +Это сеть, бесконечно сотканная рукой Тзинча. +Каждый узел — это жизнь. Каждый разрыв — +рождение новой нити, более сложной и прекрасной. +Смерть — лишь ещё один узор в его гобелене.`, + en: `Fate is not a thread that can be severed. +It is a web, endlessly woven by the hand of Tzeentch. +Every knot is a life. Every break +births a new thread, more intricate and beautiful. +Death is merely another pattern in his tapestry.`, + source: "Liber Malefic, фрагмент XVII / Liber Malefic, Fragment XVII", + }, + { + ru: `«Зачем служить Тзинчу?» — спросил послушник. +«Потому что ты уже служишь ему», — ответил магистр. +«Как это возможно? Я ещё не давал клятвы». +«Ты задал этот вопрос. Этого достаточно».`, + en: `"Why serve Tzeentch?" asked the novice. +"Because you already do," replied the magister. +"How can that be? I have sworn no oath." +"You asked that question. That is enough."`, + source: "Диалоги Тысячи Сынов / Dialogues of the Thousand Sons", + }, + { + ru: `Магнус видел всё. +Видел падение Просперо. Видел предательство. +Видел собственное проклятие. +И всё равно сделал именно то, +что от него ожидал Тзинч. +Всё идёт по плану.`, + en: `Magnus saw everything. +He saw the fall of Prospero. Saw the betrayal. +Saw his own damnation. +And still he did precisely what +Tzeentch had always intended. +All is proceeding as planned.`, + source: "Ересь Хоруса, том XI / Horus Heresy, Vol. XI", + }, + { + ru: `Не бойся перемен, смертный. +Бойся их отсутствия. +Ибо то, что не меняется — уже мертво. +А мёртвое принадлежит другому богу.`, + en: `Fear not change, mortal. +Fear its absence. +For that which does not change is already dead. +And the dead belong to another god.`, + source: "Слова Аримана / Words of Ahriman of the Thousand Sons", + }, + { + ru: `В начале было Слово. +Слово было «Изменись». +И Вселенная изменилась. +Так было. Так есть. Так будет. +Тзинч доволен.`, + en: `In the beginning was the Word. +The Word was "Change". +And the Universe changed. +So it was. So it is. So it shall be. +Tzeentch is pleased.`, + source: "Книга Первичного Знания, утеряна / The Book of Primordial Knowledge, lost", + }, + { + ru: `Каждый чародей думает, что контролирует варп. +Варп думает иначе. +Тзинч смеётся над обоими.`, + en: `Every sorcerer believes he controls the warp. +The warp disagrees. +Tzeentch laughs at both.`, + source: "Предупреждения инквизиции, том DCCXII / Inquisitorial Warnings, Vol. DCCXII", + }, +} + +// === Рендер попапа Тзинча === +// +// Реализован как часть основной модели (m.showTzeentch), а не отдельная +// Bubble Tea программа. Запуск вложенного tea.NewProgram внутри уже +// работающей программы ломает alt-screen и оставляет терминал в +// неконсистентном состоянии после выхода. + +// renderTzeentch рендерит попап с цитатой поверх текущего экрана +func renderTzeentch(m *EditorModel) string { + if m.width == 0 { + return "" + } + + q := tzeentchQuotes[m.tzeentchIdx] + + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("129")). + Padding(1, 3) + + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("129")). + Bold(true).Italic(true) + + eyeStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("93")).Bold(true) + + ruStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("183")) + + enStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("60")).Italic(true) + + sourceStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")).Italic(true) + + divStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("54")) + + hintStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) + + div := divStyle.Render(strings.Repeat("─", 48)) + + ruLines := strings.Split(q.ru, "\n") + var ruRendered []string + for _, l := range ruLines { + ruRendered = append(ruRendered, ruStyle.Render(l)) + } + + enLines := strings.Split(q.en, "\n") + var enRendered []string + for _, l := range enLines { + enRendered = append(enRendered, enStyle.Render(l)) + } + + content := strings.Join([]string{ + eyeStyle.Render(" ⌬"), + titleStyle.Render("✦ Tzeentch speaks... ✦"), + div, + "", + strings.Join(ruRendered, "\n"), + "", + divStyle.Render(strings.Repeat("·", 48)), + "", + strings.Join(enRendered, "\n"), + "", + div, + sourceStyle.Render("— " + q.source), + "", + hintStyle.Render("[ any key ] Just as planned."), + }, "\n") + + box := borderStyle.Render(content) + + boxLines := strings.Split(box, "\n") + boxW := 0 + for _, l := range boxLines { + if w := visibleWidth(l); w > boxW { + boxW = w + } + } + hPad := (m.width - boxW) / 2 + if hPad < 0 { + hPad = 0 + } + vPad := (m.height - len(boxLines)) / 2 + if vPad < 1 { + vPad = 1 + } + + var out strings.Builder + for i := 0; i < vPad; i++ { + out.WriteString("\n") + } + for _, l := range boxLines { + out.WriteString(strings.Repeat(" ", hPad)) + out.WriteString(l) + out.WriteString("\n") + } + return out.String() +} + +// tzeentchRand — единый генератор на весь процесс. +// Пересоздание rand.New(rand.NewSource(time.Now().UnixNano())) при каждом +// вызове даёт одинаковый seed при быстрых повторных нажатиях (разрешение +// UnixNano недостаточно при интервалах < 1мкс между вызовами), из-за чего +// показывается одна и та же цитата. Единый генератор инициализируется +// один раз и просто продолжает свою последовательность. +var tzeentchRand = rand.New(rand.NewSource(time.Now().UnixNano())) + +// triggerTzeentch выбирает случайную цитату (отличную от предыдущей) и включает попап +func (m *EditorModel) triggerTzeentch() { + if len(tzeentchQuotes) <= 1 { + m.tzeentchIdx = 0 + m.showTzeentch = true + return + } + next := tzeentchRand.Intn(len(tzeentchQuotes)) + // Избегаем повтора той же цитаты что была показана прошлый раз + for next == m.tzeentchIdx { + next = tzeentchRand.Intn(len(tzeentchQuotes)) + } + m.tzeentchIdx = next + m.showTzeentch = true +} \ No newline at end of file diff --git a/untitled.md b/untitled.md new file mode 100644 index 0000000..532de1f --- /dev/null +++ b/untitled.md @@ -0,0 +1,2 @@ +rwerwer +werwerdawdawd wDdwdadd daddwd wdwad `w \ No newline at end of file diff --git a/v-curl-ispravili-uyazvimost-25-letnej-davnosti.md b/v-curl-ispravili-uyazvimost-25-letnej-davnosti.md new file mode 100644 index 0000000..9958526 --- /dev/null +++ b/v-curl-ispravili-uyazvimost-25-letnej-davnosti.md @@ -0,0 +1,29 @@ +# В curl исправили уязвимость 25-летней давности + +> Источник: [xakep.ru](https://xakep.ru/2026/06/29/curl-old-bug/) +> Дата: 30.06.2026 + +--- + +[![PENTEST AWARD 2026](http://static.xakep.ru/advert/2026/pentest-award-2026.png) + +Рекомендуем почитать:[![](https://xakep.ru/wp-content/uploads/2026/05/575151/326-210x280.jpg)[ + +### Хакер #326. Router from Hell + + +- [Содержание выпуска +- [Подписка на «Хакер»-60% + +Разработчики curl выпустили[версию 8.21.0, в которой исправили сразу восемнадцать уязвимостей. Это рекордное число проблем, устраненных в одном обновлении. При этом один из багов присутствовал в коде более 25 лет. + +Четырем уязвимостям был присвоен средний уровень опасности, а остальным четырнадцати — низкий. Самой старой проблемой в этом релизе оказалась[CVE-2026-8932, присутствовавшая в коде проекта более 25 лет. Появившись еще в curl версии 7.7, выпущенной 22 марта 2001 года, уязвимость затрагивала все версии до 8.20.0 включительно. + + +Проблему CVE-2026-8932 обнаружили специалисты компании[Aisleс помощью собственной ИИ-платформы. Они сообщают, что баг был связан с повторным использованием mTLS-соединений. Дело в том, что libcurl хранит открытые соединения в пуле, чтобы по возможности задействовать их при следующих запросах. Однако при проверке, подходит ли существующее соединение для нового запроса, библиотека учитывала не все параметры клиентского сертификата и приватного ключа. + +В результате libcurl могла повторно использовать уже установленное соединение, даже если приложение изменило сертификат или приватный ключ. Новый запрос при этом отправлялся через соединение, аутентифицированное с прежними параметрами, и фактически выполнялся от имени прежнего клиента. + +Подчеркивается, что уязвимость затрагивает приложения, в которые встроена libcurl, но не распространяется на консольную утилиту curl. + +Аналитики Aisle отмечают, что все простые уязвимости в curl давно нашли и исправили. Теперь исследователям приходится искать баги в старых имплементациях протоколов, логике переиспользования соединений, обработчиках обратных вызовов и других редко используемых участках кода.