diff options
| author | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-01-21 20:22:09 +0100 |
|---|---|---|
| committer | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-01-21 20:22:09 +0100 |
| commit | 5a8dbc6347b3541e84fe669b22c17ad3b715e258 (patch) | |
| tree | b148c450939688caaaeb4adac6f2faa1eaffe649 /command.go | |
| download | qwe-editor-5a8dbc6347b3541e84fe669b22c17ad3b715e258.tar.gz | |
Engage!
Diffstat (limited to 'command.go')
| -rw-r--r-- | command.go | 521 |
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) + } +} |
