491 lines
No EOL
12 KiB
Go
Executable file
491 lines
No EOL
12 KiB
Go
Executable file
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 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 "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
|
||
} |