1package main
2
3// Colon command handler (e.g., :q, :w, :help). It processes strings entered in
4// ModeCommand and executes the corresponding actions.
5
6import (
7 "bufio"
8 "fmt"
9 "os"
10 "os/exec"
11 "strconv"
12 "strings"
13
14 "github.com/nsf/termbox-go"
15)
16
17// Command provides a context for executing editor commands.
18type Command struct {
19 e *Editor
20}
21
22// IsValidCommand returns true if the command should be saved to history.
23// Line numbers (pure integers) are not saved to history.
24func (ch *Command) IsValidCommand(cmd string) bool {
25 cmd = strings.TrimSpace(cmd)
26 if cmd == "" {
27 return false
28 }
29
30 // Check if it's a pure number (line jump command) - don't save to history
31 if _, err := strconv.Atoi(cmd); err == nil {
32 return false
33 }
34
35 // Valid if it's a known command
36 switch cmd {
37 case "q", "Q", "q!", "Q!", "w", "W", "wa", "WA", "wq", "WQ", "waq", "WAQ", "reload", "bd", "bd!", "debug", "help", "mouse", "e", "edit", "n":
38 return true
39 }
40
41 // Valid if it starts with ! (shell command) or r! (read shell)
42 if strings.HasPrefix(cmd, "!") || strings.HasPrefix(cmd, "r!") {
43 return true
44 }
45
46 // Valid if it starts with w (write with filename)
47 if strings.HasPrefix(cmd, "w ") {
48 return true
49 }
50
51 // Everything else is considered invalid (will show "Command not found" message)
52 return false
53}
54
55// HandleAndSaveToHistory executes a command and saves it to history if valid.
56func (ch *Command) HandleAndSaveToHistory(cmd string) {
57 ch.Handle(cmd)
58 // Save to history only if it's a valid command (not a line number, not unrecognized)
59 if ch.IsValidCommand(cmd) {
60 if len(ch.e.commandHistory) == 0 || ch.e.commandHistory[len(ch.e.commandHistory)-1] != cmd {
61 ch.e.commandHistory = append(ch.e.commandHistory, cmd)
62 }
63 }
64}
65
66// NavigateHistoryUp moves backward through command history.
67func (ch *Command) NavigateHistoryUp() {
68 if len(ch.e.commandHistory) == 0 {
69 return
70 }
71
72 if ch.e.commandHistoryIdx == -1 {
73 // Starting navigation from the end
74 ch.e.commandHistoryIdx = len(ch.e.commandHistory) - 1
75 } else if ch.e.commandHistoryIdx > 0 {
76 ch.e.commandHistoryIdx--
77 }
78
79 if ch.e.commandHistoryIdx >= 0 && ch.e.commandHistoryIdx < len(ch.e.commandHistory) {
80 ch.e.commandBuffer = []rune(ch.e.commandHistory[ch.e.commandHistoryIdx])
81 ch.e.commandCursorX = len(ch.e.commandBuffer)
82 }
83}
84
85// NavigateHistoryDown moves forward through command history.
86func (ch *Command) NavigateHistoryDown() {
87 if ch.e.commandHistoryIdx == -1 {
88 return
89 }
90
91 ch.e.commandHistoryIdx++
92 if ch.e.commandHistoryIdx >= len(ch.e.commandHistory) {
93 // Reached the end, clear the buffer
94 ch.e.commandHistoryIdx = -1
95 ch.e.commandBuffer = []rune{}
96 ch.e.commandCursorX = 0
97 } else {
98 ch.e.commandBuffer = []rune(ch.e.commandHistory[ch.e.commandHistoryIdx])
99 ch.e.commandCursorX = len(ch.e.commandBuffer)
100 }
101}
102
103// Handle parses and executes a command string.
104func (ch *Command) Handle(cmd string) {
105 cmd = strings.TrimSpace(cmd)
106 switch {
107 case cmd == "q" || cmd == "Q":
108 ch.quit(false)
109 case cmd == "q!" || cmd == "Q!":
110 ch.quit(true)
111 case cmd == "w" || cmd == "W":
112 ch.write("")
113 case strings.HasPrefix(cmd, "w "):
114 filename := strings.TrimSpace(strings.TrimPrefix(cmd, "w "))
115 ch.write(filename)
116 case cmd == "wa" || cmd == "WA":
117 ch.writeAll()
118 case cmd == "wq" || cmd == "WQ":
119 ch.writeQuit()
120 case cmd == "waq" || cmd == "WAQ":
121 ch.writeAll()
122 // Check if any buffers are still modified (meaning save failed)
123 hasModified := false
124 for _, b := range ch.e.buffers {
125 if b.modified && b.filename != "" && !b.readOnly {
126 hasModified = true
127 break
128 }
129 }
130 // Only quit if all files were saved successfully
131 if !hasModified {
132 ch.quit(false)
133 }
134 case cmd == "reload":
135 ch.reload()
136 case cmd == "bd":
137 ch.bufferDelete(false)
138 case cmd == "bd!":
139 ch.bufferDelete(true)
140 case cmd == "n":
141 ch.e.NewBuffer()
142 case cmd == "debug":
143 ch.e.toggleDebugWindow()
144 case cmd == "help":
145 // Load help content from the embedded filesystem.
146 f, err := ContentFS.Open("content/help.txt")
147 if err != nil {
148 ch.e.message = fmt.Sprintf("Error opening help: %v", err)
149 } else {
150 defer f.Close()
151 err = ch.e.LoadFromReader("help.txt", f)
152 if err != nil {
153 ch.e.message = fmt.Sprintf("Error loading help: %v", err)
154 } else {
155 // Help is read-only to prevent accidental edits.
156 b := ch.e.activeBuffer()
157 if b != nil {
158 b.readOnly = true
159 ch.e.message = "Help opened (Read-Only)"
160 }
161 }
162 }
163 case cmd == "mouse":
164 ch.toggleMouse()
165 case strings.HasPrefix(cmd, "e ") || strings.HasPrefix(cmd, "edit "):
166 filename := ""
167 if strings.HasPrefix(cmd, "e ") {
168 filename = strings.TrimSpace(strings.TrimPrefix(cmd, "e "))
169 } else {
170 filename = strings.TrimSpace(strings.TrimPrefix(cmd, "edit "))
171 }
172 if filename != "" {
173 err := ch.e.LoadFile(filename)
174 if err != nil {
175 ch.e.message = fmt.Sprintf("Error opening file: %v", err)
176 } else {
177 ch.e.message = fmt.Sprintf("Opened: %s", filename)
178 }
179 } else {
180 ch.e.message = "No filename specified"
181 }
182 case cmd == "e" || cmd == "edit":
183 ch.e.message = "No filename specified"
184 default:
185 if cmd == "" {
186 break
187 }
188 // If the command starts with r!, execute it and insert output into buffer.
189 if strings.HasPrefix(cmd, "r!") {
190 shellCmd := strings.TrimPrefix(cmd, "r!")
191 ch.readShell(shellCmd)
192 break
193 }
194 // If the command starts with !, execute it as a shell command.
195 if strings.HasPrefix(cmd, "!") {
196 shellCmd := strings.TrimPrefix(cmd, "!")
197 ch.executeShell(shellCmd)
198 break
199 }
200 // If the command is a number, jump to that line.
201 if lineNum, err := strconv.Atoi(cmd); err == nil {
202 ch.goToLine(lineNum)
203 } else {
204 ch.e.message = fmt.Sprintf("Command not found: %s", cmd)
205 }
206 }
207 // After executing a command, return to Normal mode and clear the command buffer.
208 if ch.e.mode == ModeCommand {
209 ch.e.mode = ModeNormal
210 }
211 ch.e.commandBuffer = []rune{}
212}
213
214// quit exits the editor, checking for unsaved changes unless 'force' is true.
215func (ch *Command) quit(force bool) {
216 if !force {
217 // Check if any buffer has unsaved changes
218 for _, b := range ch.e.buffers {
219 if b.modified {
220 ch.e.message = "No write since last change (use :q! to override)"
221 return
222 }
223 }
224 }
225 termbox.Close()
226 os.Exit(0)
227}
228
229// write saves the current active buffer to disk.
230func (ch *Command) write(filename string) {
231 if filename != "" {
232 b := ch.e.activeBuffer()
233 if b != nil {
234 b.filename = filename
235 b.fileType = getFileType(filename)
236 }
237 }
238 err := ch.e.SaveFile(false)
239 if err != nil {
240 // Handle conflict if the file was changed externally.
241 if err.Error() == "file changed on disk" {
242 ch.e.message = "File changed on disk. Overwrite? (y/n) "
243 ch.e.mode = ModeConfirm
244 ch.e.pendingConfirm = func() {
245 err := ch.e.SaveFile(true) // Force overwrite.
246 if err != nil {
247 ch.e.message = err.Error()
248 } else {
249 name := ch.e.activeBuffer().filename
250 if name == "" {
251 name = "[No Name]"
252 }
253 ch.e.message = fmt.Sprintf("\"%s\" written", name)
254 }
255 }
256 } else {
257 ch.e.message = err.Error()
258 }
259 } else {
260 name := ch.e.activeBuffer().filename
261 if name == "" {
262 name = "[No Name]"
263 }
264 ch.e.message = fmt.Sprintf("\"%s\" written", name)
265 }
266}
267
268// writeQuit saves the current buffer and exits.
269func (ch *Command) writeQuit() {
270 err := ch.e.SaveFile(false)
271 if err != nil {
272 if err.Error() == "file changed on disk" {
273 ch.e.message = "File changed on disk. Overwrite? (y/n) "
274 ch.e.mode = ModeConfirm
275 ch.e.pendingConfirm = func() {
276 err := ch.e.SaveFile(true)
277 if err == nil {
278 termbox.Close()
279 os.Exit(0)
280 } else {
281 ch.e.message = err.Error()
282 }
283 }
284 } else {
285 ch.e.message = err.Error()
286 }
287 } else {
288 termbox.Close()
289 os.Exit(0)
290 }
291}
292
293// writeAll saves all open buffers to disk.
294func (ch *Command) writeAll() {
295 savedCount := 0
296 var lastErr error
297
298 // Iterate through all buffers and save each one.
299 for _, b := range ch.e.buffers {
300 // Skip buffers without filenames (e.g., [No Name] buffers).
301 if b.filename == "" {
302 continue
303 }
304
305 // Skip read-only buffers.
306 if b.readOnly {
307 continue
308 }
309
310 // Save the buffer using the same logic as SaveFile but for each buffer.
311 file, err := os.Create(b.filename)
312 if err != nil {
313 lastErr = err
314 continue
315 }
316
317 writer := bufio.NewWriter(file)
318 for i, line := range b.buffer {
319 _, err := writer.WriteString(string(line))
320 if err != nil {
321 file.Close()
322 lastErr = err
323 continue
324 }
325 // Write newline if not the last line (or if buffer should end with newline).
326 if i < len(b.buffer)-1 || (len(b.buffer) > 0 && (len(b.buffer) > 1 || len(b.buffer[0]) > 0)) {
327 _, err = writer.WriteString("\n")
328 if err != nil {
329 file.Close()
330 lastErr = err
331 continue
332 }
333 }
334 }
335
336 err = writer.Flush()
337 file.Close()
338
339 if err == nil {
340 b.modified = false
341 info, err := os.Stat(b.filename)
342 if err == nil {
343 b.lastModTime = info.ModTime()
344 }
345 savedCount++
346 } else {
347 lastErr = err
348 }
349 }
350
351 // Display appropriate message.
352 if lastErr != nil {
353 ch.e.message = fmt.Sprintf("Error saving some files: %v", lastErr)
354 } else if savedCount == 0 {
355 ch.e.message = "No files to save"
356 } else if savedCount == 1 {
357 ch.e.message = "1 file written"
358 } else {
359 ch.e.message = fmt.Sprintf("%d files written", savedCount)
360 }
361}
362
363// bufferDelete closes the currently active buffer.
364func (ch *Command) bufferDelete(force bool) {
365 b := ch.e.activeBuffer()
366 if !force && b != nil && b.modified {
367 ch.e.message = "No write since last change (use :bd! to override)"
368 return
369 }
370 ch.e.deleteCurrentBuffer()
371}
372
373// toggleMouse enables/disables mouse interaction in the terminal.
374func (ch *Command) toggleMouse() {
375 ch.e.mouseEnabled = !ch.e.mouseEnabled
376 if ch.e.mouseEnabled {
377 termbox.SetInputMode(termbox.InputEsc | termbox.InputMouse)
378 } else {
379 termbox.SetInputMode(termbox.InputEsc)
380 }
381}
382
383// goToLine moves the cursor to the beginning of the specified line number.
384func (ch *Command) goToLine(lineNum int) {
385 b := ch.e.activeBuffer()
386 if b != nil {
387 targetY := lineNum - 1 // Convert 1-based UI line number to 0-based index.
388 if targetY < 0 {
389 targetY = 0
390 }
391 if targetY >= len(b.buffer) {
392 targetY = len(b.buffer) - 1
393 }
394 b.PrimaryCursor().Y = targetY
395 b.PrimaryCursor().X = 0
396 ch.e.centerCursor()
397 }
398}
399
400// reload re-reads the active buffer from disk.
401func (ch *Command) reload() {
402 b := ch.e.activeBuffer()
403 if b != nil {
404 err := ch.e.ReloadBuffer(b)
405 if err != nil {
406 ch.e.message = fmt.Sprintf("Reload failed: %v", err)
407 } else {
408 ch.e.message = fmt.Sprintf("\"%s\" reloaded", b.filename)
409 }
410 }
411}
412
413// executeShell runs a shell command and displays the output.
414func (ch *Command) executeShell(shellCmd string) {
415 shellCmd = strings.TrimSpace(shellCmd)
416 if shellCmd == "" {
417 ch.e.message = "No shell command specified"
418 return
419 }
420
421 // Execute the command using sh -c for proper shell interpretation.
422 cmd := exec.Command("/bin/sh", "-c", shellCmd)
423 output, err := cmd.CombinedOutput()
424
425 if err != nil {
426 // Display error along with any output that was produced.
427 if len(output) > 0 {
428 ch.e.message = fmt.Sprintf("Error: %v | Output: %s", err, strings.TrimSpace(string(output)))
429 } else {
430 ch.e.message = fmt.Sprintf("Error executing command: %v", err)
431 }
432 return
433 }
434
435 // Display the command output, truncating if too long.
436 outputStr := strings.TrimSpace(string(output))
437 if outputStr == "" {
438 ch.e.message = "Command executed successfully (no output)"
439 } else {
440 // Truncate output if it's too long for the message bar.
441 const maxLen = 200
442 if len(outputStr) > maxLen {
443 ch.e.message = outputStr[:maxLen] + "..."
444 } else {
445 ch.e.message = outputStr
446 }
447 }
448}
449
450// readShell runs a shell command and inserts the output into the buffer at cursor position.
451func (ch *Command) readShell(shellCmd string) {
452 shellCmd = strings.TrimSpace(shellCmd)
453 if shellCmd == "" {
454 ch.e.message = "No shell command specified"
455 return
456 }
457
458 b := ch.e.activeBuffer()
459 if b == nil {
460 return
461 }
462
463 if b.readOnly {
464 ch.e.message = "File is read-only"
465 return
466 }
467
468 // Execute the command using sh -c for proper shell interpretation.
469 cmd := exec.Command("/bin/sh", "-c", shellCmd)
470 output, err := cmd.CombinedOutput()
471
472 if err != nil {
473 // Display error along with any output that was produced.
474 if len(output) > 0 {
475 ch.e.message = fmt.Sprintf("Error: %v | Output: %s", err, strings.TrimSpace(string(output)))
476 } else {
477 ch.e.message = fmt.Sprintf("Error executing command: %v", err)
478 }
479 return
480 }
481
482 outputStr := string(output)
483 if outputStr == "" {
484 ch.e.message = "Command executed (no output to insert)"
485 return
486 }
487
488 // Save state for undo.
489 ch.e.saveState()
490
491 // Split output into lines and insert them into the buffer.
492 lines := strings.Split(outputStr, "\n")
493 // Remove trailing empty line if present (common with command output).
494 if len(lines) > 0 && lines[len(lines)-1] == "" {
495 lines = lines[:len(lines)-1]
496 }
497
498 if len(lines) == 0 {
499 ch.e.message = "Command executed (no output to insert)"
500 return
501 }
502
503 c := b.PrimaryCursor()
504 currentY := c.Y
505
506 // Insert output starting from the line after the cursor.
507 for i, line := range lines {
508 insertY := currentY + i + 1
509 // Create new line in buffer.
510 newLine := []rune(line)
511 // Insert the line into the buffer.
512 if insertY <= len(b.buffer) {
513 b.buffer = append(b.buffer[:insertY], append([][]rune{newLine}, b.buffer[insertY:]...)...)
514 } else {
515 b.buffer = append(b.buffer, newLine)
516 }
517 }
518
519 // Mark buffer as modified.
520 ch.e.markModified()
521
522 // Reparse syntax if needed.
523 if b.syntax != nil {
524 b.syntax.Reparse([]byte(b.toString()))
525 }
526
527 // Notify LSP of the change.
528 if b.lspClient != nil {
529 b.lspClient.SendDidChange(b.toString())
530 }
531
532 lineCount := len(lines)
533 if lineCount == 1 {
534 ch.e.message = "1 line inserted"
535 } else {
536 ch.e.message = fmt.Sprintf("%d lines inserted", lineCount)
537 }
538}