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.SetCommandError(fmt.Errorf("unsaved changes to '%s'", buf.Filename)) 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.SetCommandError(err) } 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.SetCommandError(err) 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.SetCommandError(err) 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.SetCommandError(err) 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.SetCommandError(fmt.Errorf(":edit requires an argument")) 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.SetCommandError(err) 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 { // TODO: This is temporary, for debugging if len(args) < 1 { m.SetCommandError(fmt.Errorf("Please provide a name. Register dump not yet implemented.")) return nil } if len(args[0]) != 1 { m.SetCommandError(fmt.Errorf("Name should be a single character.")) return nil } name := rune(args[0][0]) reg, found := m.GetRegister(name) if !found { m.SetCommandError(fmt.Errorf("Could not find register '%c'.", name)) return nil } content := strings.Join(reg.Content, "\\n") t := reg.Type m.SetCommandOutput(fmt.Sprintf("Type: %d Name: \"%c Content: %s", t, name, content)) 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 { out := fmt.Sprintf("%+v", m.Settings()) m.SetCommandOutput(out) return nil } for _, arg := range args { if err := parseSetOption(m, arg); err != nil { m.SetCommandError(err) 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 }