grimoir/commands.go
2026-07-04 13:04:33 +03:00

491 lines
No EOL
12 KiB
Go
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
// :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 <N>")
return nil
}
// === Простые команды ===
switch cmd {
case "help", "h":
m.showHelp = true
return nil
case "tzeentch":
m.triggerTzeentch()
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 "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 <path> or saveas <path>")
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.previewDirty = true
m.mode = NORMAL
m.updatePreview()
m.SetStatusMsg("New file (save with :w <name>)")
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 <URL>")
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 <subcommand>
func (m *EditorModel) handleConfigCommand(sub string) {
switch sub {
case "", "show":
m.SetStatusMsg(fmt.Sprintf(
"Config: %s | pw=%d nu=%v tab=%d autosave=%ds",
m.config.LoadedFrom, 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 pw=%d tab=%d autosave=%d",
m.showLineNumbers, m.showPreview,
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 116")
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 "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
}