summaryrefslogtreecommitdiff
path: root/command.go
diff options
context:
space:
mode:
Diffstat (limited to 'command.go')
-rw-r--r--command.go521
1 files changed, 521 insertions, 0 deletions
diff --git a/command.go b/command.go
new file mode 100644
index 0000000..e6fc9eb
--- /dev/null
+++ b/command.go
@@ -0,0 +1,521 @@
+package main
+
+// Colon command handler (e.g., :q, :w, :help). It processes strings entered in
+// ModeCommand and executes the corresponding actions.
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+
+ "github.com/nsf/termbox-go"
+)
+
+// Command provides a context for executing editor commands.
+type Command struct {
+ e *Editor
+}
+
+// IsValidCommand returns true if the command should be saved to history.
+// Line numbers (pure integers) are not saved to history.
+func (ch *Command) IsValidCommand(cmd string) bool {
+ cmd = strings.TrimSpace(cmd)
+ if cmd == "" {
+ return false
+ }
+
+ // Check if it's a pure number (line jump command) - don't save to history
+ if _, err := strconv.Atoi(cmd); err == nil {
+ return false
+ }
+
+ // Valid if it's a known command
+ switch cmd {
+ case "q", "Q", "q!", "Q!", "w", "W", "wa", "WA", "wq", "WQ", "waq", "WAQ", "reload", "bd", "bd!", "debug", "help", "mouse", "e", "edit":
+ return true
+ }
+
+ // Valid if it starts with ! (shell command) or r! (read shell)
+ if strings.HasPrefix(cmd, "!") || strings.HasPrefix(cmd, "r!") {
+ return true
+ }
+
+ // Everything else is considered invalid (will show "Command not found" message)
+ return false
+}
+
+// HandleAndSaveToHistory executes a command and saves it to history if valid.
+func (ch *Command) HandleAndSaveToHistory(cmd string) {
+ ch.Handle(cmd)
+ // Save to history only if it's a valid command (not a line number, not unrecognized)
+ if ch.IsValidCommand(cmd) {
+ if len(ch.e.commandHistory) == 0 || ch.e.commandHistory[len(ch.e.commandHistory)-1] != cmd {
+ ch.e.commandHistory = append(ch.e.commandHistory, cmd)
+ }
+ }
+}
+
+// NavigateHistoryUp moves backward through command history.
+func (ch *Command) NavigateHistoryUp() {
+ if len(ch.e.commandHistory) == 0 {
+ return
+ }
+
+ if ch.e.commandHistoryIdx == -1 {
+ // Starting navigation from the end
+ ch.e.commandHistoryIdx = len(ch.e.commandHistory) - 1
+ } else if ch.e.commandHistoryIdx > 0 {
+ ch.e.commandHistoryIdx--
+ }
+
+ if ch.e.commandHistoryIdx >= 0 && ch.e.commandHistoryIdx < len(ch.e.commandHistory) {
+ ch.e.commandBuffer = []rune(ch.e.commandHistory[ch.e.commandHistoryIdx])
+ ch.e.commandCursorX = len(ch.e.commandBuffer)
+ }
+}
+
+// NavigateHistoryDown moves forward through command history.
+func (ch *Command) NavigateHistoryDown() {
+ if ch.e.commandHistoryIdx == -1 {
+ return
+ }
+
+ ch.e.commandHistoryIdx++
+ if ch.e.commandHistoryIdx >= len(ch.e.commandHistory) {
+ // Reached the end, clear the buffer
+ ch.e.commandHistoryIdx = -1
+ ch.e.commandBuffer = []rune{}
+ ch.e.commandCursorX = 0
+ } else {
+ ch.e.commandBuffer = []rune(ch.e.commandHistory[ch.e.commandHistoryIdx])
+ ch.e.commandCursorX = len(ch.e.commandBuffer)
+ }
+}
+
+// Handle parses and executes a command string.
+func (ch *Command) Handle(cmd string) {
+ cmd = strings.TrimSpace(cmd)
+ switch {
+ case cmd == "q" || cmd == "Q":
+ ch.quit(false)
+ case cmd == "q!" || cmd == "Q!":
+ ch.quit(true)
+ case cmd == "w" || cmd == "W":
+ ch.write()
+ case cmd == "wa" || cmd == "WA":
+ ch.writeAll()
+ case cmd == "wq" || cmd == "WQ":
+ ch.writeQuit()
+ case cmd == "waq" || cmd == "WAQ":
+ ch.writeAll()
+ // Check if any buffers are still modified (meaning save failed)
+ hasModified := false
+ for _, b := range ch.e.buffers {
+ if b.modified && b.filename != "" && !b.readOnly {
+ hasModified = true
+ break
+ }
+ }
+ // Only quit if all files were saved successfully
+ if !hasModified {
+ ch.quit(false)
+ }
+ case cmd == "reload":
+ ch.reload()
+ case cmd == "bd":
+ ch.bufferDelete(false)
+ case cmd == "bd!":
+ ch.bufferDelete(true)
+ case cmd == "debug":
+ ch.e.toggleDebugWindow()
+ case cmd == "help":
+ // Load help content from the embedded filesystem.
+ f, err := ContentFS.Open("content/help.txt")
+ if err != nil {
+ ch.e.message = fmt.Sprintf("Error opening help: %v", err)
+ } else {
+ defer f.Close()
+ err = ch.e.LoadFromReader("help.txt", f)
+ if err != nil {
+ ch.e.message = fmt.Sprintf("Error loading help: %v", err)
+ } else {
+ // Help is read-only to prevent accidental edits.
+ b := ch.e.activeBuffer()
+ if b != nil {
+ b.readOnly = true
+ ch.e.message = "Help opened (Read-Only)"
+ }
+ }
+ }
+ case cmd == "mouse":
+ ch.toggleMouse()
+ case strings.HasPrefix(cmd, "e ") || strings.HasPrefix(cmd, "edit "):
+ filename := ""
+ if strings.HasPrefix(cmd, "e ") {
+ filename = strings.TrimSpace(strings.TrimPrefix(cmd, "e "))
+ } else {
+ filename = strings.TrimSpace(strings.TrimPrefix(cmd, "edit "))
+ }
+ if filename != "" {
+ err := ch.e.LoadFile(filename)
+ if err != nil {
+ ch.e.message = fmt.Sprintf("Error opening file: %v", err)
+ } else {
+ ch.e.message = fmt.Sprintf("Opened: %s", filename)
+ }
+ } else {
+ ch.e.message = "No filename specified"
+ }
+ case cmd == "e" || cmd == "edit":
+ ch.e.message = "No filename specified"
+ default:
+ if cmd == "" {
+ break
+ }
+ // If the command starts with r!, execute it and insert output into buffer.
+ if strings.HasPrefix(cmd, "r!") {
+ shellCmd := strings.TrimPrefix(cmd, "r!")
+ ch.readShell(shellCmd)
+ break
+ }
+ // If the command starts with !, execute it as a shell command.
+ if strings.HasPrefix(cmd, "!") {
+ shellCmd := strings.TrimPrefix(cmd, "!")
+ ch.executeShell(shellCmd)
+ break
+ }
+ // If the command is a number, jump to that line.
+ if lineNum, err := strconv.Atoi(cmd); err == nil {
+ ch.goToLine(lineNum)
+ } else {
+ ch.e.message = fmt.Sprintf("Command not found: %s", cmd)
+ }
+ }
+ // After executing a command, return to Normal mode and clear the command buffer.
+ if ch.e.mode == ModeCommand {
+ ch.e.mode = ModeNormal
+ }
+ ch.e.commandBuffer = []rune{}
+}
+
+// quit exits the editor, checking for unsaved changes unless 'force' is true.
+func (ch *Command) quit(force bool) {
+ if !force {
+ // Check if any buffer has unsaved changes
+ for _, b := range ch.e.buffers {
+ if b.modified {
+ ch.e.message = "No write since last change (use :q! to override)"
+ return
+ }
+ }
+ }
+ termbox.Close()
+ os.Exit(0)
+}
+
+// write saves the current active buffer to disk.
+func (ch *Command) write() {
+ err := ch.e.SaveFile(false)
+ if err != nil {
+ // Handle conflict if the file was changed externally.
+ if err.Error() == "file changed on disk" {
+ ch.e.message = "File changed on disk. Overwrite? (y/n) "
+ ch.e.mode = ModeConfirm
+ ch.e.pendingConfirm = func() {
+ err := ch.e.SaveFile(true) // Force overwrite.
+ if err != nil {
+ ch.e.message = err.Error()
+ } else {
+ name := ch.e.activeBuffer().filename
+ if name == "" {
+ name = "[No Name]"
+ }
+ ch.e.message = fmt.Sprintf("\"%s\" written", name)
+ }
+ }
+ } else {
+ ch.e.message = err.Error()
+ }
+ } else {
+ name := ch.e.activeBuffer().filename
+ if name == "" {
+ name = "[No Name]"
+ }
+ ch.e.message = fmt.Sprintf("\"%s\" written", name)
+ }
+}
+
+// writeQuit saves the current buffer and exits.
+func (ch *Command) writeQuit() {
+ err := ch.e.SaveFile(false)
+ if err != nil {
+ if err.Error() == "file changed on disk" {
+ ch.e.message = "File changed on disk. Overwrite? (y/n) "
+ ch.e.mode = ModeConfirm
+ ch.e.pendingConfirm = func() {
+ err := ch.e.SaveFile(true)
+ if err == nil {
+ termbox.Close()
+ os.Exit(0)
+ } else {
+ ch.e.message = err.Error()
+ }
+ }
+ } else {
+ ch.e.message = err.Error()
+ }
+ } else {
+ termbox.Close()
+ os.Exit(0)
+ }
+}
+
+// writeAll saves all open buffers to disk.
+func (ch *Command) writeAll() {
+ savedCount := 0
+ var lastErr error
+
+ // Iterate through all buffers and save each one.
+ for _, b := range ch.e.buffers {
+ // Skip buffers without filenames (e.g., [No Name] buffers).
+ if b.filename == "" {
+ continue
+ }
+
+ // Skip read-only buffers.
+ if b.readOnly {
+ continue
+ }
+
+ // Save the buffer using the same logic as SaveFile but for each buffer.
+ file, err := os.Create(b.filename)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+
+ writer := bufio.NewWriter(file)
+ for i, line := range b.buffer {
+ _, err := writer.WriteString(string(line))
+ if err != nil {
+ file.Close()
+ lastErr = err
+ continue
+ }
+ // Write newline if not the last line (or if buffer should end with newline).
+ if i < len(b.buffer)-1 || (len(b.buffer) > 0 && (len(b.buffer) > 1 || len(b.buffer[0]) > 0)) {
+ _, err = writer.WriteString("\n")
+ if err != nil {
+ file.Close()
+ lastErr = err
+ continue
+ }
+ }
+ }
+
+ err = writer.Flush()
+ file.Close()
+
+ if err == nil {
+ b.modified = false
+ info, err := os.Stat(b.filename)
+ if err == nil {
+ b.lastModTime = info.ModTime()
+ }
+ savedCount++
+ } else {
+ lastErr = err
+ }
+ }
+
+ // Display appropriate message.
+ if lastErr != nil {
+ ch.e.message = fmt.Sprintf("Error saving some files: %v", lastErr)
+ } else if savedCount == 0 {
+ ch.e.message = "No files to save"
+ } else if savedCount == 1 {
+ ch.e.message = "1 file written"
+ } else {
+ ch.e.message = fmt.Sprintf("%d files written", savedCount)
+ }
+}
+
+// bufferDelete closes the currently active buffer.
+func (ch *Command) bufferDelete(force bool) {
+ b := ch.e.activeBuffer()
+ if !force && b != nil && b.modified {
+ ch.e.message = "No write since last change (use :bd! to override)"
+ return
+ }
+ ch.e.deleteCurrentBuffer()
+}
+
+// toggleMouse enables/disables mouse interaction in the terminal.
+func (ch *Command) toggleMouse() {
+ ch.e.mouseEnabled = !ch.e.mouseEnabled
+ if ch.e.mouseEnabled {
+ termbox.SetInputMode(termbox.InputEsc | termbox.InputMouse)
+ } else {
+ termbox.SetInputMode(termbox.InputEsc)
+ }
+}
+
+// goToLine moves the cursor to the beginning of the specified line number.
+func (ch *Command) goToLine(lineNum int) {
+ b := ch.e.activeBuffer()
+ if b != nil {
+ targetY := lineNum - 1 // Convert 1-based UI line number to 0-based index.
+ if targetY < 0 {
+ targetY = 0
+ }
+ if targetY >= len(b.buffer) {
+ targetY = len(b.buffer) - 1
+ }
+ b.PrimaryCursor().Y = targetY
+ b.PrimaryCursor().X = 0
+ ch.e.centerCursor()
+ }
+}
+
+// reload re-reads the active buffer from disk.
+func (ch *Command) reload() {
+ b := ch.e.activeBuffer()
+ if b != nil {
+ err := ch.e.ReloadBuffer(b)
+ if err != nil {
+ ch.e.message = fmt.Sprintf("Reload failed: %v", err)
+ } else {
+ ch.e.message = fmt.Sprintf("\"%s\" reloaded", b.filename)
+ }
+ }
+}
+
+// executeShell runs a shell command and displays the output.
+func (ch *Command) executeShell(shellCmd string) {
+ shellCmd = strings.TrimSpace(shellCmd)
+ if shellCmd == "" {
+ ch.e.message = "No shell command specified"
+ return
+ }
+
+ // Execute the command using sh -c for proper shell interpretation.
+ cmd := exec.Command("/bin/sh", "-c", shellCmd)
+ output, err := cmd.CombinedOutput()
+
+ if err != nil {
+ // Display error along with any output that was produced.
+ if len(output) > 0 {
+ ch.e.message = fmt.Sprintf("Error: %v | Output: %s", err, strings.TrimSpace(string(output)))
+ } else {
+ ch.e.message = fmt.Sprintf("Error executing command: %v", err)
+ }
+ return
+ }
+
+ // Display the command output, truncating if too long.
+ outputStr := strings.TrimSpace(string(output))
+ if outputStr == "" {
+ ch.e.message = "Command executed successfully (no output)"
+ } else {
+ // Truncate output if it's too long for the message bar.
+ const maxLen = 200
+ if len(outputStr) > maxLen {
+ ch.e.message = outputStr[:maxLen] + "..."
+ } else {
+ ch.e.message = outputStr
+ }
+ }
+}
+
+// readShell runs a shell command and inserts the output into the buffer at cursor position.
+func (ch *Command) readShell(shellCmd string) {
+ shellCmd = strings.TrimSpace(shellCmd)
+ if shellCmd == "" {
+ ch.e.message = "No shell command specified"
+ return
+ }
+
+ b := ch.e.activeBuffer()
+ if b == nil {
+ return
+ }
+
+ if b.readOnly {
+ ch.e.message = "File is read-only"
+ return
+ }
+
+ // Execute the command using sh -c for proper shell interpretation.
+ cmd := exec.Command("/bin/sh", "-c", shellCmd)
+ output, err := cmd.CombinedOutput()
+
+ if err != nil {
+ // Display error along with any output that was produced.
+ if len(output) > 0 {
+ ch.e.message = fmt.Sprintf("Error: %v | Output: %s", err, strings.TrimSpace(string(output)))
+ } else {
+ ch.e.message = fmt.Sprintf("Error executing command: %v", err)
+ }
+ return
+ }
+
+ outputStr := string(output)
+ if outputStr == "" {
+ ch.e.message = "Command executed (no output to insert)"
+ return
+ }
+
+ // Save state for undo.
+ ch.e.saveState()
+
+ // Split output into lines and insert them into the buffer.
+ lines := strings.Split(outputStr, "\n")
+ // Remove trailing empty line if present (common with command output).
+ if len(lines) > 0 && lines[len(lines)-1] == "" {
+ lines = lines[:len(lines)-1]
+ }
+
+ if len(lines) == 0 {
+ ch.e.message = "Command executed (no output to insert)"
+ return
+ }
+
+ c := b.PrimaryCursor()
+ currentY := c.Y
+
+ // Insert output starting from the line after the cursor.
+ for i, line := range lines {
+ insertY := currentY + i + 1
+ // Create new line in buffer.
+ newLine := []rune(line)
+ // Insert the line into the buffer.
+ if insertY <= len(b.buffer) {
+ b.buffer = append(b.buffer[:insertY], append([][]rune{newLine}, b.buffer[insertY:]...)...)
+ } else {
+ b.buffer = append(b.buffer, newLine)
+ }
+ }
+
+ // Mark buffer as modified.
+ ch.e.markModified()
+
+ // Reparse syntax if needed.
+ if b.syntax != nil {
+ b.syntax.Reparse([]byte(b.toString()))
+ }
+
+ // Notify LSP of the change.
+ if b.lspClient != nil {
+ b.lspClient.SendDidChange(b.toString())
+ }
+
+ lineCount := len(lines)
+ if lineCount == 1 {
+ ch.e.message = "1 line inserted"
+ } else {
+ ch.e.message = fmt.Sprintf("%d lines inserted", lineCount)
+ }
+}