package main import ( "fmt" "path/filepath" "strconv" "strings" tea "github.com/charmbracelet/bubbletea" ) // executeCommand выполняет vim-команду func (m *EditorModel) executeCommand(cmd string) tea.Cmd { cmd = strings.TrimSpace(cmd) // === Команды с аргументами (проверяем первыми) === // :w path — сохранить в другой файл (saveas без смены текущего) if strings.HasPrefix(cmd, "w ") { target := strings.TrimSpace(strings.TrimPrefix(cmd, "w ")) return m.cmdWriteTo(target, false) } // :saveas path / :wa path if strings.HasPrefix(cmd, "saveas ") { target := strings.TrimSpace(strings.TrimPrefix(cmd, "saveas ")) return m.cmdWriteTo(target, true) } // :e path / :edit path — открыть файл if strings.HasPrefix(cmd, "e ") || strings.HasPrefix(cmd, "edit ") { target := strings.TrimSpace(cmd) target = strings.TrimSpace(strings.SplitN(target, " ", 2)[1]) return m.cmdEdit(target) } // :new path — создать новый файл if strings.HasPrefix(cmd, "new") { arg := strings.TrimSpace(strings.TrimPrefix(cmd, "new")) return m.cmdNew(arg) } // :export pdf [path] if strings.HasPrefix(cmd, "export") { arg := strings.TrimSpace(strings.TrimPrefix(cmd, "export")) return m.cmdExport(arg) } // :fetch URL — скачать статью и открыть в редакторе if strings.HasPrefix(cmd, "fetch ") { rawURL := strings.TrimSpace(strings.TrimPrefix(cmd, "fetch ")) return m.cmdFetch(rawURL) } // :s/old/new/[g] if strings.HasPrefix(cmd, "s/") { m.handleSubstituteCommand(cmd) return nil } // :/pattern if strings.HasPrefix(cmd, "/") { m.performSearch(strings.TrimPrefix(cmd, "/")) return nil } // :set ... if strings.HasPrefix(cmd, "set") { m.handleSetCommand(cmd) return nil } // :config ... if strings.HasPrefix(cmd, "config") { m.handleConfigCommand(strings.TrimSpace(strings.TrimPrefix(cmd, "config"))) 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) if len(parts) == 2 { if n, err := strconv.Atoi(parts[1]); err == nil { m.previewWidth = n m.clampPanelWidths() m.SetStatusMsg(fmt.Sprintf("Preview width: %d", m.previewWidth)) return nil } } m.SetStatusMsg("Usage: previewwidth ") return nil } // === Простые команды === switch cmd { case "help", "h": m.showHelp = true return nil case "w": if err := m.save(); err != nil { m.SetStatusMsg("Error: " + err.Error()) } else { m.SetStatusMsg(fmt.Sprintf("Saved: %s", m.filepath)) } return nil case "q": if m.dirty { m.SetStatusMsg("No write since last change (add ! to override)") } else { return tea.Quit } return nil case "q!": return tea.Quit case "wq", "x": if err := m.save(); err != nil { m.SetStatusMsg("Error: " + err.Error()) } else { return tea.Quit } return nil case "u": 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("") case "export", "export pdf": return m.cmdExport("pdf") } // :N — перейти на строку if len(cmd) > 0 && cmd[0] >= '0' && cmd[0] <= '9' { lineNum := 0 if _, err := fmt.Sscanf(cmd, "%d", &lineNum); err == nil { if lineNum > 0 && lineNum <= len(m.lines) { m.cursorY = lineNum - 1 m.cursorX = 0 m.SetStatusMsg(fmt.Sprintf("Line %d", lineNum)) return nil } m.SetStatusMsg(fmt.Sprintf("Invalid line: %d", lineNum)) return nil } } m.SetStatusMsg("Not an editor command: " + cmd) return nil } // === Реализации команд === // cmdWriteTo сохраняет в указанный путь. // changeFilepath=true означает ":saveas" — меняем текущий файл. // changeFilepath=false означает ":w path" — копия, текущий файл не меняется. func (m *EditorModel) cmdWriteTo(target string, changeFilepath bool) tea.Cmd { if target == "" { m.SetStatusMsg("Usage: w or saveas ") return nil } target = expandPath(target) // Сохраняем под новым именем content := strings.Join(m.lines, "\n") if err := writeFileImpl(target, content); err != nil { m.SetStatusMsg("Error: " + err.Error()) return nil } if changeFilepath { // saveas — переключаемся на новый файл m.filepath = target m.fileType = DetectFileType(target) m.dirty = false m.cfgShowPreview = m.cfgShowPreview m.showPreview = m.fileType == "md" && m.cfgShowPreview m.clampPanelWidths() m.updatePreview() m.SetStatusMsg(fmt.Sprintf("Saved as: %s", target)) } else { // w path — просто копия m.SetStatusMsg(fmt.Sprintf("Written to: %s", target)) } return nil } // cmdEdit открывает файл в редакторе (как :e в vim) func (m *EditorModel) cmdEdit(target string) tea.Cmd { if target == "" { // :e без аргумента — перезагрузить текущий файл if m.dirty { m.SetStatusMsg("No write since last change (add ! to override with :e!)") return nil } m.openFile(m.filepath) m.SetStatusMsg("Reloaded: " + m.filepath) return nil } target = expandPath(target) if m.dirty { m.SetStatusMsg(fmt.Sprintf("Save changes first (:w), or use :e! to discard — opening %s", target)) // Открываем всё равно, как vim с confirm } m.openFile(target) return nil } // cmdNew создаёт новый пустой файл func (m *EditorModel) cmdNew(name string) tea.Cmd { if m.dirty { m.SetStatusMsg("Unsaved changes! Save first (:w) or discard (:q!), then :new") return nil } if name == "" { // Без имени — открываем пустой буфер untitled m.lines = []string{""} m.filepath = "untitled.md" m.fileType = "md" m.cursorX = 0 m.cursorY = 0 m.dirty = false m.undoStack = nil m.redoStack = nil m.searchMatches = nil m.showPreview = m.cfgShowPreview m.mode = NORMAL m.updatePreview() m.SetStatusMsg("New file (save with :w )") return nil } name = expandPath(name) // Если файл уже существует — открываем его content, err := LoadFile(name) if err != nil { m.SetStatusMsg("Error: " + err.Error()) return nil } m.openFile(name) if content == "" { m.SetStatusMsg(fmt.Sprintf("New file: %s", name)) } else { m.SetStatusMsg(fmt.Sprintf("Opened: %s", name)) } return nil } // cmdExport экспортирует текущий файл func (m *EditorModel) cmdExport(arg string) tea.Cmd { parts := strings.Fields(arg) format := "pdf" outputPath := "" if len(parts) >= 1 { format = strings.ToLower(parts[0]) } if len(parts) >= 2 { outputPath = expandPath(parts[1]) } switch format { case "pdf": if m.fileType != "md" { m.SetStatusMsg("PDF export only supported for .md files") return nil } if outputPath == "" { outputPath = defaultPDFPath(m.filepath) } m.SetStatusMsg(fmt.Sprintf("Exporting PDF: %s ...", outputPath)) if err := ExportToPDF(m.lines, outputPath); err != nil { m.SetStatusMsg("Export error: " + err.Error()) } else { m.SetStatusMsg(fmt.Sprintf("Exported: %s", outputPath)) } default: m.SetStatusMsg(fmt.Sprintf("Unknown export format: %s (supported: pdf)", format)) } return nil } // === Вспомогательные === // cmdFetch скачивает статью по URL и открывает в редакторе func (m *EditorModel) cmdFetch(rawURL string) tea.Cmd { if rawURL == "" { m.SetStatusMsg("Usage: fetch ") return nil } if !IsURL(rawURL) { m.SetStatusMsg("Invalid URL (must start with http:// or https://)") return nil } m.SetStatusMsg(fmt.Sprintf("Fetching: %s ...", rawURL)) result, err := FetchAndConvert(rawURL) if err != nil { m.SetStatusMsg("Fetch error: " + err.Error()) return nil } savedPath, err := SaveFetchResult(result, "") if err != nil { m.SetStatusMsg("Save error: " + err.Error()) return nil } m.openFile(savedPath) m.SetStatusMsg(fmt.Sprintf("Fetched: %s → %s", result.Title, savedPath)) return nil } // expandPath разворачивает ~ в домашнюю директорию func expandPath(path string) string { if strings.HasPrefix(path, "~/") || path == "~" { if home, err := userHomeDir(); err == nil { path = filepath.Join(home, path[1:]) } } return path } // handleConfigCommand обрабатывает :config 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, m.showLineNumbers, m.tabWidth, m.autosaveInterval, )) case "paths": paths := ConfigPaths() m.SetStatusMsg("Search paths: " + strings.Join(paths[:min3(2, len(paths))], ", ") + " ...") case "write": paths := ConfigPaths() target := "" for _, p := range paths { if p != paths[0] { target = p break } } if target == "" && len(paths) > 0 { target = paths[0] } if err := WriteDefaultConfig(target); err != nil { m.SetStatusMsg("Error writing config: " + err.Error()) } else { m.SetStatusMsg("Config written: " + target) } case "reload": cfg := LoadConfig() m.ApplyConfig(cfg) m.SetStatusMsg("Config reloaded from: " + cfg.LoadedFrom) default: m.SetStatusMsg("Usage: :config [show|paths|write|reload]") } } // handleSubstituteCommand обрабатывает :s/old/new/[g] func (m *EditorModel) handleSubstituteCommand(cmd string) { parts := strings.Split(cmd, "/") if len(parts) < 3 { m.SetStatusMsg("Usage: s/pattern/replacement/[g]") return } old := parts[1] newStr := parts[2] flags := "" if len(parts) > 3 { flags = parts[3] } if old == "" { m.SetStatusMsg("Empty pattern") return } m.saveUndoSnapshot() if strings.Contains(flags, "g") { count := m.replaceAll(old, newStr) m.SetStatusMsg(fmt.Sprintf("Replaced %d occurrence(s)", count)) } else { count := m.replaceInLine(old, newStr) if count > 0 { m.SetStatusMsg(fmt.Sprintf("Replaced %d occurrence(s)", count)) } else { m.SetStatusMsg("Pattern not found") } } } // handleSetCommand обрабатывает :set option[=value] 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, )) return } switch rest { case "number", "nu": m.showLineNumbers = true m.SetStatusMsg("Line numbers ON") case "nonumber", "nonu": m.showLineNumbers = false m.SetStatusMsg("Line numbers OFF") case "preview": if m.fileType == "md" { m.showPreview = true m.clampPanelWidths() m.SetStatusMsg("Preview ON") } else { m.SetStatusMsg("Preview only for .md files") } case "nopreview": m.showPreview = false m.SetStatusMsg("Preview OFF") default: if eqIdx := strings.Index(rest, "="); eqIdx != -1 { key := rest[:eqIdx] val := rest[eqIdx+1:] switch key { case "tabstop", "ts", "tab_width", "tabwidth": n, err := strconv.Atoi(val) if err != nil || n < 1 || n > 16 { m.SetStatusMsg("tab_width must be 1–16") return } m.tabWidth = n m.SetStatusMsg(fmt.Sprintf("Tab width: %d", n)) case "autosave": n, err := strconv.Atoi(val) if err != nil || n < 0 { m.SetStatusMsg("autosave must be >= 0") return } m.autosaveInterval = n if n == 0 { m.SetStatusMsg("Autosave OFF") } 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 { m.SetStatusMsg("Expected integer") return } m.previewWidth = n m.clampPanelWidths() m.SetStatusMsg(fmt.Sprintf("Preview width: %d", m.previewWidth)) default: m.SetStatusMsg("Unknown option: " + key) } } else { m.SetStatusMsg("Unknown option: " + rest) } } } func min3(a, b int) int { if a < b { return a } return b }