Gim/internal/command/handlers.go
Hayden Hargreaves 10e37b82af
All checks were successful
Run Test Suite / test (push) Successful in 13s
feat: implemented the command window! Not tested. Maybe we need some?
2026-03-14 23:13:59 -07:00

497 lines
12 KiB
Go

package command
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// QuitMsg: Message signaling the application should quit.
type QuitMsg struct{}
// ErrorMsg: Message signaling an error to display.
type ErrorMsg struct {
Err error
}
// --------------------------------------------------
// Quit Commands
// --------------------------------------------------
// cmdQuit: Handles :quit / :q command.
func cmdQuit(m action.Model, args []string, force bool) tea.Cmd {
// :q! forces quit, ignoring unsaved changes
if force {
return tea.Quit
}
bufs := m.Buffers()
// Cannot exit if any buffer has unsaved changes
for _, buf := range bufs {
if buf.Modified {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{fmt.Sprintf("unsaved changes to '%s'", buf.Filename)},
Inline: true,
IsError: true,
})
m.ActiveWindow().SetBuffer(buf)
return nil
}
}
return tea.Quit
}
// cmdQuitAll: Handles :qall / :qa command.
func cmdQuitAll(m action.Model, args []string, force bool) tea.Cmd {
// TODO: Until splits are implemented, this is the same as cmdQuit
return cmdQuit(m, args, force)
}
// --------------------------------------------------
// File Commands (write & edit)
// --------------------------------------------------
// cmdWrite: Handles :write / :w command
func cmdWrite(m action.Model, args []string, force bool) tea.Cmd {
buf := m.ActiveBuffer()
cmd, err := writeBuffer(m, buf, args, force)
if err != nil {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{err.Error()},
Inline: true,
IsError: true,
})
}
return cmd
}
// cmdWriteAll: Handles :wall / :wa command
func cmdWriteAll(m action.Model, args []string, force bool) tea.Cmd {
var cmds []tea.Cmd
bufs := m.Buffers()
for _, buf := range bufs {
if buf.Modified {
cmd, err := writeBuffer(m, buf, args, force)
if err != nil {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{err.Error()},
Inline: true,
IsError: true,
})
return nil
}
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
// cmdWriteQuit: Handles :wq command
func cmdWriteQuit(m action.Model, args []string, force bool) tea.Cmd {
buf := m.ActiveBuffer()
cmd, err := writeBuffer(m, buf, args, force)
if err != nil {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{err.Error()},
Inline: true,
IsError: true,
})
return cmd
}
return tea.Batch(cmd, tea.Quit)
}
// cmdWriteQuitAll: Handles :wqall / :wqa / :xa command.
// Writes all modified buffers then quits.
func cmdWriteQuitAll(m action.Model, args []string, force bool) tea.Cmd {
var cmds []tea.Cmd
bufs := m.Buffers()
for _, buf := range bufs {
if buf.Modified {
cmd, err := writeBuffer(m, buf, args, force)
if err != nil {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{err.Error()},
Inline: true,
IsError: true,
})
return nil
}
cmds = append(cmds, cmd)
}
}
cmds = append(cmds, tea.Quit)
return tea.Batch(cmds...)
}
// cmdEdit: Handles :edit / :e
func cmdEdit(m action.Model, args []string, force bool) tea.Cmd {
// must have arguments, cant edit nothing
if len(args) < 1 {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{":edit requires an argument"},
Inline: true,
IsError: true,
})
return nil
}
// Vim's Approach:
// " When you do :edit filename.txt
// 1. Check if file exists and is readable (if not, open new buffer)
// 2. Detect file encoding (UTF-8, etc.)
// 3. Read entire file into memory
// 4. Split by line endings (respecting fileformat)
// 5. Create new buffer with these lines
// 6. Set buffer metadata:
// - buftype = "" (normal file)
// - modified = 0 (not modified)
// - fileformat = "unix" | "dos" | "mac"
// - fileencoding = "utf-8" (etc.)
filename := args[0]
ext := filepath.Ext(filename)
// If the buffer already exists, just switch to it.
bufs := m.Buffers()
for _, buf := range bufs {
if buf.Filename == filename {
m.ActiveWindow().SetBuffer(buf)
return nil
}
}
file, err := os.Open(filename)
notFound := errors.Is(err, os.ErrNotExist)
if err != nil && !notFound {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{err.Error()},
Inline: true,
IsError: true,
})
return nil
}
if file != nil {
defer file.Close()
}
// Create a buffer with the new file name, writing the file will
// handle the saving logic
if notFound {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithFiletype(ext).
Listed().
Loaded().
Build()
m.SetBuffers(append(m.Buffers(), &buf))
m.ActiveWindow().SetBuffer(&buf)
// Need to adjust the cursor when we make a new file
m.ActiveWindow().ClampCursor()
return nil
}
var lines []string
// BUG: We are unable to open and edit files owned by root. How do we handle that?
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSuffix(line, "\r")
// BUG: This is bad, we don't want to this, but we have to
cleaned := strings.ReplaceAll(line, "\t", strings.Repeat(" ", m.Settings().TabStop))
lines = append(lines, cleaned)
}
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithFiletype(ext).
WithLines(lines).
Listed().
Loaded().
Build()
m.SetBuffers(append(m.Buffers(), &buf))
m.ActiveWindow().SetBuffer(&buf)
return nil
}
// --------------------------------------------------
// Register Commands
// --------------------------------------------------
// cmdRegisters: Handles :register command (debug - displays register content).
func cmdRegisters(m action.Model, args []string, force bool) tea.Cmd {
if len(args) < 1 {
regs := m.Registers()
lines := []string{"Type Name Content"}
for name, reg := range regs {
if len(reg.Content) > 0 {
line := fmt.Sprintf(
" %s \"%c %s",
reg.Type.ToString(),
name,
strings.Join(reg.Content, "\\n"),
)
lines = append(lines, line)
}
}
m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{
Title: ":reg",
Lines: lines,
Inline: false,
IsError: false,
})
return nil
}
// BUG: We can actually handle many now
// if len(args[0]) != 1 {
// m.SetCommandOutput(&core.CommandOutput{
// Lines: []string{"Name should be a single character."},
// Inline: true,
// IsError: true,
// })
// return nil
// }
names := []rune(args[0])
lines := []string{"Type Name Content"}
for _, name := range names {
reg, ok := m.GetRegister(name)
if ok && len(reg.Content) > 0 {
line := fmt.Sprintf(
" %s \"%c %s",
reg.Type.ToString(),
name,
strings.Join(reg.Content, "\\n"),
)
lines = append(lines, line)
}
}
m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{
Title: ":reg",
Lines: lines,
Inline: false,
IsError: false,
})
return nil
}
// --------------------------------------------------
// Settings Commands
// --------------------------------------------------
// cmdSet: Handles :set option[=value] command for configuring editor settings.
// Examples:
//
// :set number - enable number
// :set nonumber - disable number
// :set number! - toggle number
// :set tabstop=4 - set tabstop to 4
// :set ts=4 - set tabstop to 4 (abbreviation)
func cmdSet(m action.Model, args []string, force bool) tea.Cmd {
if len(args) == 0 {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{fmt.Sprintf("%+v", m.Settings())},
Inline: true,
})
return nil
}
for _, arg := range args {
if err := parseSetOption(m, arg); err != nil {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{err.Error()},
Inline: true,
IsError: true,
})
return nil
}
}
return nil
}
// Setting: Represents a configurable editor option.
type Setting struct {
Name string
ShortForm string
Type SettingType
Get func(m action.Model) any
Set func(m action.Model, val any)
}
// SettingType: Enumeration of setting value types.
type SettingType int
const (
BoolSetting SettingType = iota
IntSetting
StringSetting
)
// settingsMap defines all available settings (both global and window-local)
var settingsMap = []Setting{
// Global editor settings
{
Name: "tabstop",
ShortForm: "ts",
Type: IntSetting,
Get: func(m action.Model) any { return m.Settings().TabStop },
Set: func(m action.Model, val any) {
s := m.Settings()
s.TabStop = val.(int)
m.SetSettings(s)
},
},
// Window-local settings
{
Name: "number",
ShortForm: "nu",
Type: BoolSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.Number },
Set: func(m action.Model, val any) {
w := m.ActiveWindow()
o := w.Options
o.Number = val.(bool)
w.SetOptions(o)
},
},
{
Name: "relativenumber",
ShortForm: "rnu",
Type: BoolSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.RelativeNumber },
Set: func(m action.Model, val any) {
w := m.ActiveWindow()
o := w.Options
o.RelativeNumber = val.(bool)
w.SetOptions(o)
},
},
{
Name: "scrolloff",
ShortForm: "so",
Type: IntSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.ScrollOff },
Set: func(m action.Model, val any) {
w := m.ActiveWindow()
o := w.Options
o.ScrollOff = val.(int)
w.SetOptions(o)
},
},
{
Name: "guttersize",
ShortForm: "gu",
Type: IntSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.GutterSize },
Set: func(m action.Model, val any) {
w := m.ActiveWindow()
o := w.Options
o.GutterSize = val.(int)
w.SetOptions(o)
},
},
}
// lookupSetting: Finds a setting by name, short form, or prefix.
func lookupSetting(name string) *Setting {
for i := range settingsMap {
s := &settingsMap[i]
if name == s.Name || name == s.ShortForm {
return s
}
// Prefix matching
if len(name) >= len(s.ShortForm) && strings.HasPrefix(s.Name, name) {
return s
}
}
return nil
}
// parseSetOption: Parses and applies a single :set option.
func parseSetOption(m action.Model, opt string) error {
// Handle toggle: option!
if name, ok := strings.CutSuffix(opt, "!"); ok {
setting := lookupSetting(name)
if setting != nil && setting.Type == BoolSetting {
currentVal := setting.Get(m).(bool)
setting.Set(m, !currentVal)
}
return nil
}
// Handle disable: nooption
if name, ok := strings.CutPrefix(opt, "no"); ok {
setting := lookupSetting(name)
if setting != nil && setting.Type == BoolSetting {
setting.Set(m, false)
}
return nil
}
// Handle assignment: option=value
if strings.Contains(opt, "=") {
parts := strings.SplitN(opt, "=", 2)
name, value := parts[0], parts[1]
setting := lookupSetting(name)
if setting != nil {
switch setting.Type {
case IntSetting:
intVal, err := strconv.Atoi(value)
if err != nil {
return err
}
setting.Set(m, intVal)
case StringSetting:
setting.Set(m, value)
case BoolSetting:
// Handle :set option=true / :set option=false
boolVal := value == "true" || value == "1" || value == "yes"
setting.Set(m, boolVal)
}
}
return nil
}
// Handle enable: option (boolean only)
setting := lookupSetting(opt)
if setting != nil && setting.Type == BoolSetting {
setting.Set(m, true)
}
return nil
}