iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Software Development

30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用系列 第 27 篇

CLI 編輯器的文字編輯:增刪、存檔與 Undo/Redo

  • 分享至 

  • xImage
  •  

完成了檔案載入與顯示後,接著要加入字元插入、刪除與換行,將修改後的內容安全寫回磁碟,並把每次修改記成可逆操作,讓 Ctrl+Z 與 Ctrl+Y 可以復原及重做。整個編輯與復原流程包含四個關鍵部分:

  1. 可編輯的文字緩衝區:把唯讀的檔案內容載入記憶體緩衝區,讓按鍵輸入能隨時修改文字,直到按下存檔才寫回磁碟。
  2. 單行與跨行編輯:處理同一行內的字元增刪,以及按下 Enter 的斷行與 Backspace 的跨行合併。
  3. 安全存檔機制:避免寫入中途中斷導致原檔案毀損,採用先寫暫存檔再替換原檔的防護流程。
  4. Undo/Redo 歷史紀錄:將每次修改記錄為可復原的操作,實現 Ctrl+Z 與 Ctrl+Y 的復原與重做。

可編輯的文字緩衝區

上一篇在開啟檔案時,已將磁碟內容載入 editor.rows 供顯示使用。當時的 rows []string 是唯讀的;這一篇把它改成可編輯的 text buffer。使用者輸入只修改 buffer,磁碟上的檔案不會立刻改變;按下 Ctrl+S 後,編輯器才把 buffer 寫回磁碟。

範例用 rows []Row 表示 text buffer,每個 Row 保存一行可編輯文字:

type Row struct {
	Chars []rune
}

type Editor struct {
	rows   []Row
	cx, cy int
	dirty  int
}

Row 與 Editor 是兩個 struct 型別。Row 表示單一行的文字內容;Editor 則保存整份文件的列陣列 rows,並用 (cx, cy) 記錄游標在文件中的位置與修改狀態。

在 Go 中,string 是唯讀的,且索引單位是 byte。中文字在 UTF-8 編碼下會占 3 個 byte;若直接對 string 用索引切分,s[0] 只會拿到中文字的三分之一,既無法代表完整字元,也無法直接修改內容。

將每一行改成 []rune 型別(Chars []rune):

  • 按字元計算索引:「你好」轉成 []rune 後只有 2 個元素,Chars[0] 代表「你」、Chars[1] 代表「好」,增刪時不必手動計算 UTF-8 的 byte 長度。
  • 可原地編輯:可以直接透過切片(slice)操作在指定位置新增或刪除字元。

單行編輯只改變目前行

當使用者在終端機中輸入一般字元(如字母、數字)或在行內按 Backspace 刪除字元時,編輯範圍僅局限在游標所在的那一行,不會增加或減少文件的總行數。

Editor 只需要透過 cy 找到對應的 Row,再將行內游標 cx 當成索引傳給 Row 的修改方法。當 Row 更新完內部的 Chars 切片後,Editor 再隨之移動游標位置。

假設這一行是 helo,游標位於索引 2,使用者輸入 X。insertRune(2, 'X') 把原本的 slice 在索引 2 切成左右兩段,再把 X 放在中間:

原始內容:he|lo
索引:    0 1  2 3

r.Chars[:2]     ch     r.Chars[2:]
    [h e]   +   [X]   +    [l o]
                 │
                 ▼
             [h e X l o]

結果:heX|lo
func (r *Row) insertRune(at int, ch rune) {
	r.Chars = append(r.Chars[:at], append([]rune{ch}, r.Chars[at:]...)...)
}

insertRune 將原本的切片在索引 at 處切成左右兩段,並將新字元 ch 放在中間,重組成插入後的新切片。

Editor.insertChar 則負責整合行寫入與游標狀態:

func (e *Editor) insertChar(ch rune) {
	if e.cy == len(e.rows) {
		e.rows = append(e.rows, Row{})
	}
	e.rows[e.cy].insertRune(e.cx, ch)
	e.cx++
	e.dirty++
}
  • 處理邊界:若游標位在檔案最後一行的下一行(cy == len(rows)),先新增空行。
  • 寫入與推進:呼叫 insertRune 在 cx 位置寫入字元,隨後將游標右移一格(cx++),並累加 dirty 記錄一次未存檔修改。

Backspace 刪除同一行的字元

游標位於 heX|lo 時按 Backspace,要刪除的是游標左邊索引 2 的 X。Editor 先把 cx-1 傳給 deleteRune,deleteRune(2) 再跳過索引 2,把它前後的內容接起來:

刪除前:heX|lo
索引:  0 1 2  3 4

r.Chars[:2]                 r.Chars[3:]
    [h e]                       [l o]
       └────────────┬─────────────┘
                    ▼
                [h e l o]

被刪除:r.Chars[2] = [X]

結果:he|lo
func (r *Row) deleteRune(at int) {
	r.Chars = append(r.Chars[:at], r.Chars[at+1:]...)
}

deleteRune 跳過索引 at 的字元,將其前後的切片重新串接,即可移除該字元。

當使用者按下 Backspace 且游標不在行首(cx > 0)時,執行單行刪除:

if e.cx > 0 {
	e.rows[e.cy].deleteRune(e.cx - 1)
	e.cx--
	e.dirty++
}

Backspace 刪除的是游標左邊的字元,因此傳給 deleteRune 的刪除索引是 cx - 1。刪除後將游標左移一格(cx--),並累加 dirty 記錄未存檔狀態。

跨行編輯:斷行與行合併

游標位於 hel|lo 時按 Enter,cx 左邊的 hel 留在目前行,右邊的 lo 移到下一行。游標跟著移到下一行行首,因此 cy 增加 1,cx 歸零:

按下 Enter 前:hel|lo

按下 Enter 後:hel
                |lo

切行過程涉及 rows 切片(slice)的插列搬動:

原本行陣列:[Row0, Row1(hel|lo), Row2]
                      │
   ┌──────────────────┴──────────────────┐
   ▼                                     ▼
Row1(hel)                             Row1_next(lo)

1. rows 擴容增加一格:[Row0, Row1, Row2, 空白]
2. copy 往下搬動一格:[Row0, Row1, Row2, Row2]
3. 放入新切出的行  :[Row0, Row1, Row1_next, Row2]
func (e *Editor) insertNewline() {
	if e.cy == len(e.rows) {
		e.rows = append(e.rows, Row{})
	}

	cur := &e.rows[e.cy]
	rest := append([]rune(nil), cur.Chars[e.cx:]...)
	cur.Chars = cur.Chars[:e.cx]

	e.rows = append(e.rows, Row{})
	copy(e.rows[e.cy+2:], e.rows[e.cy+1:])
	e.rows[e.cy+1] = Row{Chars: rest}

	e.cy++
	e.cx = 0
	e.dirty++
}

按下 Enter 時的斷行處理包含三個步驟:

  1. 拆分目前行:將游標右邊的文字複製給下一行(rest),左邊的文字則保留在原本這行。
  2. 插入新列:在 rows 中空出 cy + 1 的位置,並放入新產生的行。
  3. 更新游標:將游標移到下一行的行首(cy++ 且 cx = 0),並累加 dirty 狀態。

這個邏輯能自然涵蓋各種位置:游標在行首按 Enter 時會在目前行上方插入空行;游標在行尾按 Enter 時則會於下方新增空行。

行首 Backspace 把目前行接到上一行

游標在行首(cx == 0)按 Backspace,左邊沒有同一行的字元,不能呼叫 deleteRune(-1)。這時要把目前行接到上一行的尾端:

合併前:hello
        |world

合併後:hello|world

實作時先記住上一行的長度,因為這是合併後的游標位置;接著串接兩行的 Chars,再從 rows 刪掉目前行:

prev := &e.rows[e.cy-1]
e.cx = len(prev.Chars)                     // 游標放在接縫處
prev.Chars = append(prev.Chars, e.rows[e.cy].Chars...)
e.rows = append(e.rows[:e.cy], e.rows[e.cy+1:]...) // 刪掉這一行
e.cy--

Delete 鍵與邊界狀況

Backspace 是刪除游標左邊的字元(向左刪除),而 Delete 則是刪除游標右邊的字元(向右刪除)。

在實作 Delete(deleteForward)時,不需要重寫一套複雜的跨行合併邏輯。只要先將游標向右移動一格,再呼叫 Backspace 的刪除函式(delChar),就能達成刪除右側字元或將下一行合併上來的效果:

func (e *Editor) deleteForward() {
	if e.cy >= len(e.rows) {
		return
	}
	if e.cx == len(e.rows[e.cy].Chars) && e.cy == len(e.rows)-1 {
		return // 位於檔案最後一行的行尾,無法再向後刪除
	}
	e.moveCursor(KeyArrowRight)
	e.delChar()
}

安全的存檔機制

當使用者按下 Ctrl+S 時,編輯器需要將記憶體中的文字寫回磁碟。

最直覺的作法是呼叫 os.WriteFile 直接覆寫原檔案:

os.WriteFile(e.filename, e.rowsToBytes(), 0644)

但直接覆寫有個風險:若在寫入過程中發生程式當機、斷電或系統崩潰,舊檔案的內容已經被清空,而新內容又沒寫完整,檔案就會永久毀損。

為了解決這個問題,文字編輯器通常採用「先寫臨時檔,再改名覆寫」的安全存檔流程:

  1. 建立臨時暫存檔:在同目錄開一個臨時檔案,將新的文字內容寫進去。若寫入中途出錯,原本的檔案完好無損。
  2. 確保寫入硬碟:呼叫 tmp.Sync(),通知作業系統立刻將資料寫入實體硬碟,避免資料只留在記憶體快取中。
  3. 改名覆寫原檔:呼叫 os.Rename 將臨時檔案更名為原本的檔名。作業系統能保證改名動作只會一次成功或失敗,不會留下改到一半的損毀檔案。

程式碼實作如下:

func (e *Editor) save() error {
	// 1. 在同目錄建立臨時暫存檔
	tmp, err := os.CreateTemp(filepath.Dir(e.filename), ".editor-*")
	if err != nil {
		return err
	}
	defer os.Remove(tmp.Name()) // 確保失敗時自動刪除臨時檔

	// 2. 將記憶體文字寫入臨時檔
	if _, err := tmp.Write(e.rowsToBytes()); err != nil {
		tmp.Close()
		return err
	}

	// 3. 確保資料已真正寫入硬碟
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	tmp.Close()

	// 4. 改名覆寫原檔
	if err := os.Rename(tmp.Name(), e.filename); err != nil {
		return err
	}

	e.dirty = 0 // 存檔成功,重置修改狀態
	return nil
}

這項機制確保了存檔的安全性:存檔失敗時,錯誤只會顯示在狀態列,未存檔的文字依然安全留在記憶體中供使用者繼續編輯。

實作 Undo/Redo

Ctrl+Z 用於復原(Undo)上一次的修改,而 Ctrl+Y 則用於重做(Redo)剛被復原的操作。

在 Raw Mode 下,終端機不會把 Ctrl+Z 當成暫停程式的訊號(SIGTSTP),而是會將 Ctrl+Z 作為 0x1a(ASCII 26)、Ctrl+Y 作為 0x19(ASCII 25)傳給程式。我們可以捕捉這兩個按鍵事件,觸發編輯器的 Undo 與 Redo 邏輯。

若每次修改前都複製整份文件的副本,當檔案很大或編輯次數多時,記憶體會快速被多份文件副本吃光。因此我們不需要保存整份文件的歷史狀態,只需記錄每次修改對文字做了什麼改變。

編輯器對 buffer 的所有修改,本質上只有四種基本操作。只要上記錄操作發生的位置與內容,並為每一種操作定義對應的「逆操作」,就能用極小的記憶體還原任何歷史狀態:

type EditOp struct {
	Kind   OpKind
	Cy, Cx int
	Text   []rune
	seq    int
}

每種操作都有相對應的「逆操作」:

  • 插入(OpInsert):原操作是在 (Cy, Cx) 插入文字,逆操作即呼叫 OpDelete 刪除該段文字。
  • 刪除(OpDelete):原操作是刪除文字,逆操作即呼叫 OpInsert 將被刪除的文字插回原處。
  • 斷行(OpSplit):原操作是按下 Enter 將一行切為兩行,逆操作即呼叫 OpMerge 將下一行合併回來。
  • 合併(OpMerge):原操作是將下一行併回上一行尾端,逆操作即呼叫 OpSplit 在原本的接縫處重新切開。

例如,當游標位於 (cy: 0, cx: 2) 時輸入 X,編輯器會建立一份紀錄:

op := EditOp{Kind: OpInsert, Cy: 0, Cx: 2, Text: []rune{'X'}}

按下 Ctrl+Z 時,Undo 不需要知道檔案之前的整行長怎樣,只要將 op 傳給 inverse(op),轉換為 OpDelete 並在 (0, 2) 執行刪除,文字與游標就會精準回到修改前的狀態。


CLI 編輯器小結

在這個專案中,我們實作了以下 CLI 開發的基礎功能:

  1. 輸入接管:關閉終端機的行緩衝(Canonical Mode),直接捕捉 Raw Mode 下的單鍵與 ANSI 轉義序列鍵盤事件。
  2. 畫面控制與繪製:利用 ANSI 轉義序列定位游標與清理畫面,並透過雙重緩衝(Double Buffering)避免畫面刷新時閃爍。
  3. 內容載入與視窗滾動:維護視窗偏移(rowoff/coloff),讓終端機只繪製螢幕看得見的範圍。
  4. 動態編輯與安全存檔:以 []rune 處理字元增刪,採用寫入臨時檔再更名的安全存檔機制,並以可逆操作實現 Undo/Redo。

串連這些功能後,就能組裝出一個具備基礎顯示、游標移動、動態編輯與安全存檔的終端機文字編輯器。

下一篇

編輯器已具備輸入、繪製、載入與編輯的完整流程,但每次都靠手動操作驗證並不可靠。下一篇回到 CLI 的測試設計:先用單元測試驗證 Command 的輸出、Exit Code 與輸入組合,再以實際啟動 Process 的方式驗證完整行為。


上一篇
CLI 編輯器的檔案瀏覽:載入檔案與 Viewport
下一篇
CLI 測試:從 Command 單元測試到 Process 驗證
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言