cliedit 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,9 @@ It includes line wrapping, visual navigation, smart auto-indentation, undo/redo,
18
18
  - **Search & Replace:** `Ctrl+W` to find text, `Ctrl+R` to find and replace interactively.
19
19
  - **Go to Line:** `Ctrl+L` to quickly jump to a specific line number.
20
20
  - **Smart Auto-Indentation:** Automatically preserves indentation level when pressing Enter.
21
+ - **Smart Navigation:** `Alt + Left/Right` to jump by words, `Ctrl + M` to jump between matching brackets.
22
+ - **Piping Support:** Works with standard Unix pipes (e.g. `cat file.txt | cliedit`).
23
+ - **Crash Recovery:** Automatically saves changes to a hidden swap file (e.g. `.filename.swp`) to prevent data loss.
21
24
 
22
25
  ## Installation
23
26
  ```bash
@@ -34,35 +37,61 @@ import path from 'path';
34
37
 
35
38
  async function getCommitMessage() {
36
39
  const tempFile = path.resolve(process.cwd(), 'COMMIT_MSG.txt');
37
- console.log('Opening editor for commit message...');
40
+
41
+ // Example with custom options
42
+ const options = {
43
+ tabSize: 2,
44
+ gutterWidth: 3
45
+ };
38
46
 
39
47
  try {
40
- const result = await openEditor(tempFile);
41
-
42
- // Give the terminal a moment to restore
43
- await new Promise(res => setTimeout(res, 50));
48
+ const result = await openEditor(tempFile, options);
44
49
 
45
50
  if (result.saved) {
46
- console.log('Message saved!');
47
- console.log('---------------------');
48
- console.log(result.content);
49
- console.log('---------------------');
51
+ console.log('Message saved:', result.content);
50
52
  } else {
51
53
  console.log('Editor quit without saving.');
52
54
  }
53
55
  } catch (err) {
54
- console.error('Editor failed to start:', err);
56
+ console.error('Editor failed:', err);
55
57
  }
56
58
  }
57
59
 
58
60
  getCommitMessage();
59
61
  ```
60
62
 
63
+ ### Piping Support
64
+
65
+ `cliedit` supports standard input piping. When used in a pipeline, it reads the input content, then re-opens the TTY to allow interactive editing.
66
+
67
+ ```bash
68
+ # Edit a file using cat
69
+ cat README.md | node my-app.js
70
+
71
+ # Edit the output of a command
72
+ git diff | node my-app.js
73
+ ```
74
+
61
75
  ## Public API
62
76
 
63
- `openEditor(filepath: string)`
77
+ `openEditor(filepath: string, options?: EditorOptions)`
78
+
79
+ Opens the editor for the specified file.
80
+
81
+ - **filepath**: Path to the file to edit.
82
+ - **options**: (Optional) Configuration object.
83
+ - `tabSize`: Number of spaces for a tab (default: 4).
84
+ - `gutterWidth`: Width of the line number gutter (default: 5).
85
+
86
+ - **Returns:** `Promise<{ saved: boolean; content: string }>`
87
+ * `saved`: `true` if the user saved (Ctrl+S), `false` otherwise (Ctrl+Q).
88
+ * `content`: The final content of the file as a string.
89
+
90
+ ### Crash Recovery
91
+
92
+ `cliedit` includes a built-in safety mechanism. It periodically saves the current content to a hidden swap file (e.g., `.myfile.txt.swp`) in the same directory.
64
93
 
65
- Opens the editor for the specified file. If the file doesn't exist, it will be created upon saving.
94
+ If the process crashes or is terminated abruptly, the next time you open the file, `cliedit` will detect the swap file and automatically recover the unsaved content, displaying a `RECOVERED FROM SWAP FILE` message.
66
95
 
67
96
  - **Returns:** `Promise<{ saved: boolean; content: string }>`
68
97
  * `saved`: `true` if the user saved (Ctrl+S), `false` otherwise (Ctrl+Q).
@@ -29,6 +29,7 @@ export declare const KEYS: {
29
29
  CTRL_U: string;
30
30
  CTRL_X: string;
31
31
  CTRL_V: string;
32
+ CTRL_M: string;
32
33
  CTRL_ARROW_UP: string;
33
34
  CTRL_ARROW_DOWN: string;
34
35
  CTRL_ARROW_RIGHT: string;
package/dist/constants.js CHANGED
@@ -31,6 +31,7 @@ export const KEYS = {
31
31
  CTRL_U: '\x15', // Paste/Un-kill
32
32
  CTRL_X: '\x18', // Cut Selection
33
33
  CTRL_V: '\x16', // Paste Selection
34
+ CTRL_M: '\x0d', // Match Bracket (Ctrl+M is often Enter, but we distinguish if possible or rely on context)
34
35
  // Selection Keys (Mapped to Ctrl+Arrow for reliable detection)
35
36
  CTRL_ARROW_UP: 'C-up',
36
37
  CTRL_ARROW_DOWN: 'C-down',
package/dist/editor.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { HistoryManager } from './history.js';
2
- import { VisualRow, EditorMode } from './types.js';
2
+ import { VisualRow, EditorMode, EditorOptions } from './types.js';
3
+ import { SwapManager } from './editor.swap.js';
3
4
  import { editingMethods } from './editor.editing.js';
4
5
  import { clipboardMethods } from './editor.clipboard.js';
5
6
  import { navigationMethods } from './editor.navigation.js';
@@ -35,6 +36,7 @@ export declare class CliEditor {
35
36
  screenRows: number;
36
37
  screenCols: number;
37
38
  gutterWidth: number;
39
+ tabSize: number;
38
40
  screenStartRow: number;
39
41
  visualRows: VisualRow[];
40
42
  mode: EditorMode;
@@ -50,16 +52,22 @@ export declare class CliEditor {
50
52
  y: number;
51
53
  x: number;
52
54
  }[];
55
+ searchResultMap: Map<number, Array<{
56
+ start: number;
57
+ end: number;
58
+ }>>;
53
59
  searchResultIndex: number;
54
60
  history: HistoryManager;
61
+ swapManager: SwapManager;
55
62
  isCleanedUp: boolean;
56
63
  resolvePromise: ((value: {
57
64
  saved: boolean;
58
65
  content: string;
59
66
  }) => void) | null;
60
67
  rejectPromise: ((reason?: any) => void) | null;
68
+ inputStream: any;
61
69
  isExiting: boolean;
62
- constructor(initialContent: string, filepath: string);
70
+ constructor(initialContent: string, filepath: string, options?: EditorOptions);
63
71
  run(): Promise<{
64
72
  saved: boolean;
65
73
  content: string;
@@ -13,7 +13,7 @@ declare function insertContentAtCursor(this: CliEditor, contentLines: string[]):
13
13
  */
14
14
  declare function insertCharacter(this: CliEditor, char: string): void;
15
15
  /**
16
- * Inserts a soft tab (4 spaces).
16
+ * Inserts a soft tab (using configured tabSize).
17
17
  */
18
18
  declare function insertSoftTab(this: CliEditor): void;
19
19
  /**
@@ -44,10 +44,11 @@ function insertCharacter(char) {
44
44
  this.cursorX += char.length;
45
45
  }
46
46
  /**
47
- * Inserts a soft tab (4 spaces).
47
+ * Inserts a soft tab (using configured tabSize).
48
48
  */
49
49
  function insertSoftTab() {
50
- this.insertCharacter(' ');
50
+ const spaces = ' '.repeat(this.tabSize || 4);
51
+ this.insertCharacter(spaces);
51
52
  }
52
53
  /**
53
54
  * Inserts a new line, splitting the current line at the cursor position.
package/dist/editor.io.js CHANGED
@@ -16,6 +16,7 @@ async function saveFile() {
16
16
  const content = this.lines.join('\n');
17
17
  try {
18
18
  await fs.writeFile(this.filepath, content, 'utf-8');
19
+ await this.swapManager.clear(); // Clear swap on successful save
19
20
  this.isDirty = false; // Reset dirty flag
20
21
  this.quitConfirm = false; // Reset quit confirmation
21
22
  this.setStatusMessage(`Saved: ${this.filepath}`, 2000);
package/dist/editor.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import keypress from './vendor/keypress.js';
3
3
  import { ANSI } from './constants.js';
4
4
  import { HistoryManager } from './history.js';
5
+ import { SwapManager } from './editor.swap.js';
5
6
  // Block `declare module 'keypress'` đã bị xóa
6
7
  // Import all functional modules
7
8
  import { editingMethods } from './editor.editing.js';
@@ -18,7 +19,7 @@ const DEFAULT_STATUS = 'HELP: Ctrl+S = Save | Ctrl+Q = Quit | Ctrl+W = Find | Ct
18
19
  * Main editor class managing application state, TTY interaction, and rendering.
19
20
  */
20
21
  export class CliEditor {
21
- constructor(initialContent, filepath) {
22
+ constructor(initialContent, filepath, options = {}) {
22
23
  this.isDirty = false;
23
24
  this.cursorX = 0;
24
25
  this.cursorY = 0;
@@ -27,6 +28,7 @@ export class CliEditor {
27
28
  this.screenRows = 0;
28
29
  this.screenCols = 0;
29
30
  this.gutterWidth = 5;
31
+ this.tabSize = 4;
30
32
  this.screenStartRow = 1;
31
33
  this.visualRows = [];
32
34
  this.mode = 'edit';
@@ -39,6 +41,8 @@ export class CliEditor {
39
41
  this.replaceQuery = null; // null = Find mode, string = Replace mode
40
42
  this.goToLineQuery = ''; // For Go to Line prompt
41
43
  this.searchResults = [];
44
+ // Map<lineNumber, Array<{ start, end }>> for fast rendering lookup
45
+ this.searchResultMap = new Map();
42
46
  this.searchResultIndex = -1;
43
47
  this.isCleanedUp = false;
44
48
  this.resolvePromise = null;
@@ -50,29 +54,39 @@ export class CliEditor {
50
54
  this.lines = [''];
51
55
  }
52
56
  this.filepath = filepath;
57
+ this.gutterWidth = options.gutterWidth ?? 5;
58
+ this.tabSize = options.tabSize ?? 4;
59
+ this.inputStream = options.inputStream || process.stdin;
53
60
  this.history = new HistoryManager();
54
61
  this.saveState(true);
62
+ // Initialize SwapManager
63
+ this.swapManager = new SwapManager(this.filepath, () => this.lines.join('\n'));
55
64
  }
56
65
  // --- Lifecycle Methods ---
57
66
  run() {
58
67
  this.setupTerminal();
59
68
  this.render();
69
+ this.swapManager.start();
60
70
  return new Promise((resolve, reject) => {
61
71
  const performCleanup = (callback) => {
72
+ this.swapManager.stop(); // Stop swap interval
62
73
  if (this.isCleanedUp) {
63
74
  if (callback)
64
75
  callback();
65
76
  return;
66
77
  }
67
78
  // 1. Remove listeners immediately
68
- process.stdin.removeAllListeners('keypress');
79
+ this.inputStream.removeAllListeners('keypress');
69
80
  process.stdout.removeAllListeners('resize');
70
81
  // 2. (FIX GHOST TUI) Write exit sequence and use callback to ensure it's written
71
82
  // before Node.js fully releases the TTY.
72
- process.stdout.write(ANSI.CLEAR_SCREEN + ANSI.MOVE_CURSOR_TOP_LEFT + ANSI.SHOW_CURSOR + ANSI.EXIT_ALTERNATE_SCREEN, () => {
83
+ // Disable mouse tracking (1000 and 1006)
84
+ process.stdout.write(ANSI.CLEAR_SCREEN + ANSI.MOVE_CURSOR_TOP_LEFT + ANSI.SHOW_CURSOR + ANSI.EXIT_ALTERNATE_SCREEN + '\x1b[?1000l' + '\x1b[?1006l', () => {
73
85
  // 3. Disable TTY raw mode and pause stdin after screen is cleared
74
- process.stdin.setRawMode(false);
75
- process.stdin.pause();
86
+ if (this.inputStream.setRawMode) {
87
+ this.inputStream.setRawMode(false);
88
+ }
89
+ this.inputStream.pause();
76
90
  this.isCleanedUp = true;
77
91
  if (callback)
78
92
  callback();
@@ -87,19 +101,27 @@ export class CliEditor {
87
101
  });
88
102
  }
89
103
  setupTerminal() {
90
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
91
- throw new Error('Editor requires a TTY environment.');
104
+ // If we are using a custom inputStream (re-opened TTY), it might be a ReadStream which is TTY.
105
+ // Check if it is TTY
106
+ if (!this.inputStream.isTTY && !process.stdin.isTTY) {
107
+ // If both are not TTY, we have a problem.
108
+ // But if inputStream is our manually opened TTY, isTTY should be true.
109
+ }
110
+ if (!process.stdout.isTTY) {
111
+ throw new Error('Editor requires a TTY environment (stdout).');
92
112
  }
93
113
  this.updateScreenSize();
94
114
  this.recalculateVisualRows();
95
- // Enter alternate screen and hide cursor
96
- process.stdout.write(ANSI.ENTER_ALTERNATE_SCREEN + ANSI.HIDE_CURSOR + ANSI.CLEAR_SCREEN);
97
- process.stdin.setRawMode(true);
98
- process.stdin.resume();
99
- process.stdin.setEncoding('utf-8');
115
+ // Enter alternate screen and hide cursor + Enable SGR Mouse (1006) and Button Event (1000)
116
+ process.stdout.write(ANSI.ENTER_ALTERNATE_SCREEN + ANSI.HIDE_CURSOR + ANSI.CLEAR_SCREEN + '\x1b[?1000h' + '\x1b[?1006h');
117
+ if (this.inputStream.setRawMode) {
118
+ this.inputStream.setRawMode(true);
119
+ }
120
+ this.inputStream.resume();
121
+ this.inputStream.setEncoding('utf-8');
100
122
  // Setup keypress listener
101
- keypress(process.stdin);
102
- process.stdin.on('keypress', this.handleKeypressEvent.bind(this));
123
+ keypress(this.inputStream);
124
+ this.inputStream.on('keypress', this.handleKeypressEvent.bind(this));
103
125
  process.stdout.on('resize', this.handleResize.bind(this));
104
126
  }
105
127
  handleResize() {
@@ -10,5 +10,6 @@ export type TKeyHandlingMethods = {
10
10
  handleCharacterKey: (ch: string) => void;
11
11
  cutSelection: () => Promise<void>;
12
12
  handleSave: () => Promise<void>;
13
+ handleAltArrows: (keyName: string) => void;
13
14
  };
14
15
  export declare const keyHandlingMethods: TKeyHandlingMethods;
@@ -81,6 +81,15 @@ function handleKeypressEvent(ch, key) {
81
81
  keyName = KEYS.ENTER;
82
82
  else if (key.name === 'tab')
83
83
  keyName = KEYS.TAB;
84
+ else if (key.meta && key.name === 'left')
85
+ keyName = 'ALT_LEFT';
86
+ else if (key.meta && key.name === 'right')
87
+ keyName = 'ALT_RIGHT';
88
+ // Handle Mouse Scroll events explicitly
89
+ else if (key.name === 'scrollup')
90
+ keyName = 'SCROLL_UP';
91
+ else if (key.name === 'scrolldown')
92
+ keyName = 'SCROLL_DOWN';
84
93
  else
85
94
  keyName = key.sequence;
86
95
  }
@@ -114,7 +123,55 @@ function handleKeypressEvent(ch, key) {
114
123
  return;
115
124
  }
116
125
  // 5. Xử lý tất cả các phím lệnh/chỉnh sửa khác
117
- edited = this.handleEditKeys(keyName || ch);
126
+ if (keyName === 'SCROLL_UP') {
127
+ const scrollAmount = 3;
128
+ this.rowOffset = Math.max(0, this.rowOffset - scrollAmount);
129
+ // Adjust cursor if it falls out of view (below the viewport)
130
+ // Actually if we scroll UP, the viewport moves UP. The cursor might be BELOW the viewport.
131
+ // Wait, scroll UP means viewing lines ABOVE. Viewport index decreases.
132
+ // Cursor (if previously in view) might now be >= rowOffset + screenRows.
133
+ // We need to ensure cursor is within [rowOffset, rowOffset + screenRows - 1]
134
+ // But verify after setting rowOffset.
135
+ const currentVisualRow = this.findCurrentVisualRowIndex();
136
+ const bottomEdge = this.rowOffset + this.screenRows - 1;
137
+ if (currentVisualRow > bottomEdge) {
138
+ const targetRow = this.visualRows[bottomEdge];
139
+ this.cursorY = targetRow.logicalY;
140
+ this.cursorX = targetRow.logicalXStart;
141
+ }
142
+ else if (currentVisualRow < this.rowOffset) {
143
+ // Should not happen when scrolling up (moving viewport up), unless cursor was already above?
144
+ // If we scroll up, rowOffset decreases. Current row stays same.
145
+ // So current row > new rowOffset.
146
+ // It might be > bottomEdge.
147
+ }
148
+ // However, to be safe against 'scroll' method resetting it:
149
+ // The 'scroll' method checks:
150
+ // if (currentVisualRow < this.rowOffset) -> this.rowOffset = currentVisualRow
151
+ // if (currentVisualRow >= this.rowOffset + this.screenRows) -> this.rowOffset = ...
152
+ // So we MUST move cursor inside the new viewport.
153
+ if (currentVisualRow > bottomEdge) {
154
+ const targetRow = this.visualRows[bottomEdge];
155
+ this.cursorY = targetRow.logicalY;
156
+ this.cursorX = targetRow.logicalXStart;
157
+ }
158
+ }
159
+ else if (keyName === 'SCROLL_DOWN') {
160
+ const scrollAmount = 3;
161
+ const maxOffset = Math.max(0, this.visualRows.length - this.screenRows);
162
+ this.rowOffset = Math.min(maxOffset, this.rowOffset + scrollAmount);
163
+ // Scroll DOWN means viewport index increases.
164
+ // Cursor might be ABOVE the new viewport (currentVisualRow < rowOffset).
165
+ const currentVisualRow = this.findCurrentVisualRowIndex();
166
+ if (currentVisualRow < this.rowOffset) {
167
+ const targetRow = this.visualRows[this.rowOffset];
168
+ this.cursorY = targetRow.logicalY;
169
+ this.cursorX = targetRow.logicalXStart;
170
+ }
171
+ }
172
+ else {
173
+ edited = this.handleEditKeys(keyName || ch);
174
+ }
118
175
  }
119
176
  // 6. Cập nhật Trạng thái và Render
120
177
  if (edited) {
@@ -125,6 +182,13 @@ function handleKeypressEvent(ch, key) {
125
182
  this.render(); // Render cuối cùng (với visual rows đã được cập nhật nếu cần)
126
183
  }
127
184
  }
185
+ function handleAltArrows(keyName) {
186
+ this.clearSearchResults(); // Clear highlights on smart navigation
187
+ if (keyName === 'ALT_LEFT')
188
+ this.moveCursorByWord('left');
189
+ else if (keyName === 'ALT_RIGHT')
190
+ this.moveCursorByWord('right');
191
+ }
128
192
  /**
129
193
  * Handles all command keys in 'edit' mode.
130
194
  * Returns true if content was modified.
@@ -137,6 +201,7 @@ function handleEditKeys(key) {
137
201
  ].includes(key);
138
202
  if (isNavigation) {
139
203
  this.cancelSelection();
204
+ this.clearSearchResults(); // Clear highlights on navigation
140
205
  if (this.isMessageCustom) {
141
206
  this.setStatusMessage(this.DEFAULT_STATUS, 0);
142
207
  }
@@ -179,9 +244,11 @@ function handleEditKeys(key) {
179
244
  return false;
180
245
  // --- Editing ---
181
246
  case KEYS.ENTER:
247
+ this.clearSearchResults();
182
248
  this.insertNewLine();
183
249
  return true;
184
250
  case KEYS.BACKSPACE:
251
+ this.clearSearchResults();
185
252
  // Handle auto-pair deletion
186
253
  const line = this.lines[this.cursorY] || '';
187
254
  const charBefore = line[this.cursorX - 1];
@@ -202,12 +269,14 @@ function handleEditKeys(key) {
202
269
  }
203
270
  return true;
204
271
  case KEYS.DELETE:
272
+ this.clearSearchResults();
205
273
  if (this.selectionAnchor)
206
274
  this.deleteSelectedText();
207
275
  else
208
276
  this.deleteForward();
209
277
  return true;
210
278
  case KEYS.TAB:
279
+ this.clearSearchResults();
211
280
  this.insertSoftTab();
212
281
  return true;
213
282
  // --- Search & History ---
@@ -223,6 +292,20 @@ function handleEditKeys(key) {
223
292
  case KEYS.CTRL_G:
224
293
  this.findNext();
225
294
  return false;
295
+ // --- Smart Navigation ---
296
+ case 'ALT_LEFT':
297
+ this.moveCursorByWord('left');
298
+ return false;
299
+ case 'ALT_RIGHT':
300
+ this.moveCursorByWord('right');
301
+ return false;
302
+ case KEYS.CTRL_M: // Or any key for Bracket Match. Ctrl+M is technically Enter in some terms but if available...
303
+ // Let's use Ctrl+B (Bracket) if not taken? Ctrl+B is often bold, but here it's CLI.
304
+ // Or just check if key is match bracket key.
305
+ // Let's try to map a specific key or use Meta.
306
+ // For now, let's use Ctrl+B?
307
+ this.matchBracket();
308
+ return false;
226
309
  // ***** SỬA LỖI VISUAL *****
227
310
  // Sau khi undo/redo, chúng ta PHẢI tính toán lại visual rows
228
311
  case KEYS.CTRL_Z:
@@ -249,6 +332,7 @@ function handleEditKeys(key) {
249
332
  // Xử lý Ký tự in được
250
333
  default:
251
334
  if (key.length === 1 && key >= ' ' && key <= '~') {
335
+ this.clearSearchResults();
252
336
  this.handleCharacterKey(key);
253
337
  return true;
254
338
  }
@@ -465,4 +549,5 @@ export const keyHandlingMethods = {
465
549
  handleCharacterKey,
466
550
  cutSelection,
467
551
  handleSave,
552
+ handleAltArrows,
468
553
  };
@@ -48,5 +48,9 @@ export declare const navigationMethods: {
48
48
  scroll: typeof scroll;
49
49
  jumpToLine: typeof jumpToLine;
50
50
  enterGoToLineMode: typeof enterGoToLineMode;
51
+ moveCursorByWord: typeof moveCursorByWord;
52
+ matchBracket: typeof matchBracket;
51
53
  };
54
+ declare function moveCursorByWord(this: CliEditor, direction: 'left' | 'right'): void;
55
+ declare function matchBracket(this: CliEditor): void;
52
56
  export {};
@@ -153,4 +153,95 @@ export const navigationMethods = {
153
153
  scroll,
154
154
  jumpToLine,
155
155
  enterGoToLineMode,
156
+ moveCursorByWord,
157
+ matchBracket,
156
158
  };
159
+ function moveCursorByWord(direction) {
160
+ const line = this.lines[this.cursorY];
161
+ if (direction === 'left') {
162
+ if (this.cursorX === 0) {
163
+ if (this.cursorY > 0) {
164
+ this.cursorY--;
165
+ this.cursorX = this.lines[this.cursorY].length;
166
+ }
167
+ }
168
+ else {
169
+ // Move left until we hit a non-word char, then until we hit a word char
170
+ // Simple logic: skip whitespace, then skip word chars
171
+ let i = this.cursorX - 1;
172
+ // 1. Skip spaces if we are currently on a space
173
+ while (i > 0 && line[i] === ' ')
174
+ i--;
175
+ // 2. Skip non-spaces
176
+ while (i > 0 && line[i - 1] !== ' ')
177
+ i--;
178
+ this.cursorX = i;
179
+ }
180
+ }
181
+ else {
182
+ if (this.cursorX >= line.length) {
183
+ if (this.cursorY < this.lines.length - 1) {
184
+ this.cursorY++;
185
+ this.cursorX = 0;
186
+ }
187
+ }
188
+ else {
189
+ let i = this.cursorX;
190
+ // 1. Skip current word chars
191
+ while (i < line.length && line[i] !== ' ')
192
+ i++;
193
+ // 2. Skip spaces
194
+ while (i < line.length && line[i] === ' ')
195
+ i++;
196
+ this.cursorX = i;
197
+ }
198
+ }
199
+ }
200
+ function matchBracket() {
201
+ const line = this.lines[this.cursorY];
202
+ const char = line[this.cursorX];
203
+ const pairs = { '(': ')', '[': ']', '{': '}' };
204
+ const revPairs = { ')': '(', ']': '[', '}': '{' };
205
+ if (pairs[char]) {
206
+ // Find closing
207
+ let depth = 1;
208
+ // Search forward
209
+ for (let y = this.cursorY; y < this.lines.length; y++) {
210
+ const l = this.lines[y];
211
+ const startX = (y === this.cursorY) ? this.cursorX + 1 : 0;
212
+ for (let x = startX; x < l.length; x++) {
213
+ if (l[x] === char)
214
+ depth++;
215
+ else if (l[x] === pairs[char])
216
+ depth--;
217
+ if (depth === 0) {
218
+ this.cursorY = y;
219
+ this.cursorX = x;
220
+ this.scroll();
221
+ return;
222
+ }
223
+ }
224
+ }
225
+ }
226
+ else if (revPairs[char]) {
227
+ // Find opening
228
+ let depth = 1;
229
+ // Search backward
230
+ for (let y = this.cursorY; y >= 0; y--) {
231
+ const l = this.lines[y];
232
+ const startX = (y === this.cursorY) ? this.cursorX - 1 : l.length - 1;
233
+ for (let x = startX; x >= 0; x--) {
234
+ if (l[x] === char)
235
+ depth++;
236
+ else if (l[x] === revPairs[char])
237
+ depth--;
238
+ if (depth === 0) {
239
+ this.cursorY = y;
240
+ this.cursorX = x;
241
+ this.scroll();
242
+ return;
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
@@ -41,6 +41,11 @@ function render() {
41
41
  const displayX = cursorVisualX + this.gutterWidth;
42
42
  const displayY = this.screenStartRow + (currentVisualRowIndex - this.rowOffset);
43
43
  const selectionRange = this.getNormalizedSelection();
44
+ // Scrollbar calculations
45
+ const totalLines = this.visualRows.length;
46
+ const showScrollbar = totalLines > this.screenRows;
47
+ const thumbHeight = showScrollbar ? Math.max(1, Math.floor((this.screenRows / totalLines) * this.screenRows)) : 0;
48
+ const thumbStart = showScrollbar ? Math.floor((this.rowOffset / totalLines) * this.screenRows) : 0;
44
49
  // Draw visual rows
45
50
  for (let y = 0; y < this.screenRows; y++) {
46
51
  const visualRowIndex = y + this.rowOffset;
@@ -66,7 +71,19 @@ function render() {
66
71
  const isCursorPosition = (visualRowIndex === currentVisualRowIndex && i === cursorVisualX);
67
72
  const isSelected = selectionRange && this.isPositionInSelection(logicalY, logicalX, selectionRange);
68
73
  // Highlight search result under cursor
69
- const isSearchResult = (this.searchResultIndex !== -1 &&
74
+ // Check if this character is part of ANY search result
75
+ let isGlobalSearchResult = false;
76
+ if (this.searchResultMap.has(logicalY)) {
77
+ const matches = this.searchResultMap.get(logicalY);
78
+ for (const match of matches) {
79
+ if (logicalX >= match.start && logicalX < match.end) {
80
+ isGlobalSearchResult = true;
81
+ break;
82
+ }
83
+ }
84
+ }
85
+ // Check if this character is part of the CURRENTLY SELECTED search result
86
+ const isCurrentSearchResult = (this.searchResultIndex !== -1 &&
70
87
  this.searchResults[this.searchResultIndex]?.y === logicalY &&
71
88
  logicalX >= this.searchResults[this.searchResultIndex]?.x &&
72
89
  logicalX < (this.searchResults[this.searchResultIndex]?.x + this.searchQuery.length));
@@ -77,8 +94,12 @@ function render() {
77
94
  // Cursor is a single inverted character if not already covered by selection
78
95
  buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
79
96
  }
80
- else if (isSearchResult) {
81
- // Highlight search result
97
+ else if (isCurrentSearchResult) {
98
+ // Selected Match: Invert + Underline (if supported) or just Invert
99
+ buffer += ANSI.INVERT_COLORS + '\x1b[4m' + char + ANSI.RESET_COLORS;
100
+ }
101
+ else if (isGlobalSearchResult) {
102
+ // Global Match: Invert only
82
103
  buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
83
104
  }
84
105
  else {
@@ -93,6 +114,13 @@ function render() {
93
114
  }
94
115
  buffer += `${ANSI.CLEAR_LINE}`;
95
116
  }
117
+ // Draw Scrollbar (Phase 2)
118
+ if (showScrollbar) {
119
+ const isThumb = y >= thumbStart && y < thumbStart + thumbHeight;
120
+ const scrollChar = isThumb ? '┃' : '│';
121
+ // Move to last column and draw
122
+ buffer += `\x1b[${this.screenStartRow + y};${this.screenCols}H${ANSI.RESET_COLORS}${scrollChar}`;
123
+ }
96
124
  }
97
125
  // Draw status bar
98
126
  buffer += `\x1b[${this.screenRows + this.screenStartRow};1H`;
@@ -33,6 +33,10 @@ declare function jumpToResult(this: CliEditor, result: {
33
33
  y: number;
34
34
  x: number;
35
35
  }): void;
36
+ /**
37
+ * Clears the current search results and highlights.
38
+ */
39
+ declare function clearSearchResults(this: CliEditor): void;
36
40
  export declare const searchMethods: {
37
41
  enterFindMode: typeof enterFindMode;
38
42
  enterReplaceMode: typeof enterReplaceMode;
@@ -41,5 +45,6 @@ export declare const searchMethods: {
41
45
  replaceCurrentAndFindNext: typeof replaceCurrentAndFindNext;
42
46
  replaceAll: typeof replaceAll;
43
47
  jumpToResult: typeof jumpToResult;
48
+ clearSearchResults: typeof clearSearchResults;
44
49
  };
45
50
  export {};
@@ -29,13 +29,20 @@ function enterReplaceMode() {
29
29
  */
30
30
  function executeSearch() {
31
31
  this.searchResults = [];
32
+ this.searchResultMap.clear();
32
33
  if (this.searchQuery === '')
33
34
  return;
35
+ const queryLen = this.searchQuery.length;
34
36
  for (let y = 0; y < this.lines.length; y++) {
35
37
  const line = this.lines[y];
36
38
  let index = -1;
39
+ const lineMatches = [];
37
40
  while ((index = line.indexOf(this.searchQuery, index + 1)) !== -1) {
38
41
  this.searchResults.push({ y, x: index });
42
+ lineMatches.push({ start: index, end: index + queryLen });
43
+ }
44
+ if (lineMatches.length > 0) {
45
+ this.searchResultMap.set(y, lineMatches);
39
46
  }
40
47
  }
41
48
  this.searchResultIndex = -1;
@@ -158,6 +165,14 @@ function jumpToResult(result) {
158
165
  // Calculate new scroll offset to center the result visually
159
166
  this.rowOffset = Math.max(0, visualRowIndex - Math.floor(this.screenRows / 2));
160
167
  }
168
+ /**
169
+ * Clears the current search results and highlights.
170
+ */
171
+ function clearSearchResults() {
172
+ this.searchResults = [];
173
+ this.searchResultMap.clear();
174
+ this.searchResultIndex = -1;
175
+ }
161
176
  export const searchMethods = {
162
177
  enterFindMode,
163
178
  enterReplaceMode,
@@ -166,4 +181,5 @@ export const searchMethods = {
166
181
  replaceCurrentAndFindNext,
167
182
  replaceAll,
168
183
  jumpToResult,
184
+ clearSearchResults,
169
185
  };
@@ -0,0 +1,17 @@
1
+ export declare class SwapManager {
2
+ private filepath;
3
+ private swapPath;
4
+ private intervalId;
5
+ private contentGetter;
6
+ private lastSavedContent;
7
+ private intervalMs;
8
+ constructor(filepath: string, contentGetter: () => string);
9
+ start(): void;
10
+ stop(): void;
11
+ update(): Promise<void>;
12
+ private saveSwap;
13
+ clear(): Promise<void>;
14
+ static getSwapPath(filepath: string): string;
15
+ static check(filepath: string): Promise<boolean>;
16
+ static read(filepath: string): Promise<string>;
17
+ }
@@ -0,0 +1,83 @@
1
+ // src/editor.swap.ts
2
+ import { promises as fs } from 'fs';
3
+ import * as path from 'path';
4
+ export class SwapManager {
5
+ constructor(filepath, contentGetter) {
6
+ this.intervalId = null;
7
+ this.lastSavedContent = '';
8
+ this.intervalMs = 2000; // 2 seconds
9
+ this.filepath = filepath;
10
+ // If filepath is empty (e.g. untitled/piped), we can't really make a relative swap file easily.
11
+ // We will default to a temp file or current dir if filepath is empty.
12
+ // For now, let's assume if filepath is provided, we use it.
13
+ if (!filepath) {
14
+ this.swapPath = path.resolve('.untitled.swp');
15
+ }
16
+ else {
17
+ const dir = path.dirname(filepath);
18
+ const file = path.basename(filepath);
19
+ this.swapPath = path.join(dir, '.' + file + '.swp');
20
+ }
21
+ this.contentGetter = contentGetter;
22
+ }
23
+ start() {
24
+ if (this.intervalId)
25
+ return;
26
+ this.intervalId = setInterval(() => this.saveSwap(), this.intervalMs);
27
+ }
28
+ stop() {
29
+ if (this.intervalId) {
30
+ clearInterval(this.intervalId);
31
+ this.intervalId = null;
32
+ }
33
+ }
34
+ // Explicitly update swap (can be called on keypress if we want instant-ish updates)
35
+ async update() {
36
+ await this.saveSwap();
37
+ }
38
+ async saveSwap() {
39
+ const currentContent = this.contentGetter();
40
+ // Optimization: Don't write if nothing changed since last swap save
41
+ if (currentContent === this.lastSavedContent)
42
+ return;
43
+ try {
44
+ await fs.writeFile(this.swapPath, currentContent, 'utf-8');
45
+ this.lastSavedContent = currentContent;
46
+ }
47
+ catch (error) {
48
+ // Silently ignore swap errors to not disrupt user flow
49
+ }
50
+ }
51
+ async clear() {
52
+ this.stop();
53
+ try {
54
+ await fs.unlink(this.swapPath);
55
+ }
56
+ catch (err) {
57
+ if (err.code !== 'ENOENT') {
58
+ // ignore
59
+ }
60
+ }
61
+ }
62
+ static getSwapPath(filepath) {
63
+ if (!filepath)
64
+ return path.resolve('.untitled.swp');
65
+ const dir = path.dirname(filepath);
66
+ const file = path.basename(filepath);
67
+ return path.join(dir, '.' + file + '.swp');
68
+ }
69
+ static async check(filepath) {
70
+ const swapPath = SwapManager.getSwapPath(filepath);
71
+ try {
72
+ await fs.access(swapPath);
73
+ return true;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ static async read(filepath) {
80
+ const swapPath = SwapManager.getSwapPath(filepath);
81
+ return fs.readFile(swapPath, 'utf-8');
82
+ }
83
+ }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import { EditorOptions } from './types.js';
1
2
  /**
2
3
  * Public API function: Opens the editor.
3
4
  * Reads the file and initializes CliEditor.
4
5
  */
5
- export declare function openEditor(filepath: string): Promise<{
6
+ export declare function openEditor(filepath: string, options?: EditorOptions): Promise<{
6
7
  saved: boolean;
7
8
  content: string;
8
9
  }>;
package/dist/index.js CHANGED
@@ -1,24 +1,62 @@
1
1
  // src/index.ts
2
2
  import { promises as fs } from 'fs';
3
3
  import { CliEditor } from './editor.js';
4
+ import { SwapManager } from './editor.swap.js';
4
5
  /**
5
6
  * Public API function: Opens the editor.
6
7
  * Reads the file and initializes CliEditor.
7
8
  */
8
- export async function openEditor(filepath) {
9
- let initialContent = '';
10
- try {
11
- // 1. Read file
12
- initialContent = await fs.readFile(filepath, 'utf-8');
9
+ export async function openEditor(filepath, options) {
10
+ // 0. Handle Piping (Stdin)
11
+ let pipedContent = '';
12
+ if (!process.stdin.isTTY) {
13
+ try {
14
+ const chunks = [];
15
+ for await (const chunk of process.stdin) {
16
+ chunks.push(chunk);
17
+ }
18
+ pipedContent = Buffer.concat(chunks).toString('utf-8');
19
+ // CRITICAL: Re-open TTY for user input!
20
+ // We need to bypass the consumed stdin and open the actual terminal device.
21
+ const ttyPath = process.platform === 'win32' ? 'CONIN$' : '/dev/tty';
22
+ const ttyFd = await fs.open(ttyPath, 'r');
23
+ // Let's rely on the fact that we can construct a new ReadStream.
24
+ const { ReadStream } = await import('tty');
25
+ const ttyReadStream = new ReadStream(ttyFd.fd);
26
+ if (options) {
27
+ options.inputStream = ttyReadStream;
28
+ }
29
+ else {
30
+ options = { inputStream: ttyReadStream };
31
+ }
32
+ }
33
+ catch (e) {
34
+ console.error('Failed to read from stdin or open TTY:', e);
35
+ }
13
36
  }
14
- catch (err) {
15
- // 2. If file does not exist (ENOENT), treat it as a new file
16
- if (err.code !== 'ENOENT') {
17
- throw err; // Throw error if not 'File not found'
37
+ // Check for swap file (only if filepath provided)
38
+ if (filepath && await SwapManager.check(filepath)) {
39
+ console.log(`\x1b[33mWarning: Swap file detected for ${filepath}. Recovering content...\x1b[0m`);
40
+ await new Promise(r => setTimeout(r, 1500));
41
+ const swapContent = await SwapManager.read(filepath);
42
+ const editor = new CliEditor(swapContent, filepath, options);
43
+ editor.isDirty = true; // Mark as dirty manually to avoid potential mixin issues
44
+ editor.statusMessage = 'RECOVERED FROM SWAP FILE';
45
+ return editor.run();
46
+ }
47
+ let initialContent = pipedContent; // Default to piped content
48
+ if (filepath && !initialContent) {
49
+ try {
50
+ initialContent = await fs.readFile(filepath, 'utf-8');
51
+ }
52
+ catch (err) {
53
+ if (err.code !== 'ENOENT') {
54
+ throw err;
55
+ }
18
56
  }
19
57
  }
20
58
  // 3. Initialize and run editor
21
- const editor = new CliEditor(initialContent, filepath);
59
+ const editor = new CliEditor(initialContent, filepath, options);
22
60
  return editor.run();
23
61
  }
24
62
  // --- Public Exports ---
package/dist/types.d.ts CHANGED
@@ -16,3 +16,8 @@ export interface VisualRow {
16
16
  content: string;
17
17
  }
18
18
  export type EditorMode = 'edit' | 'search_find' | 'search_replace' | 'search_confirm' | 'goto_line';
19
+ export interface EditorOptions {
20
+ tabSize?: number;
21
+ gutterWidth?: number;
22
+ inputStream?: any;
23
+ }
@@ -18,6 +18,7 @@ if (!listenerCount) {
18
18
  */
19
19
  const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/;
20
20
  const functionKeyCodeRe = /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/;
21
+ const mouseSgrRe = /^\x1b\[<(\d+);(\d+);(\d+)([mM])/;
21
22
  /**
22
23
  * Hàm chính, chấp nhận một Readable Stream và làm cho nó
23
24
  * phát ra sự kiện "keypress".
@@ -421,7 +422,32 @@ function emitKey(stream, s) {
421
422
  }
422
423
  return;
423
424
  }
424
- // XXX: code phân tích "mouse" đã bị XÓA theo yêu cầu.
425
+ // Mouse handling (SGR 1006)
426
+ if ((parts = mouseSgrRe.exec(s))) {
427
+ // SGR Mode: \x1b[< b; x; y M/m
428
+ // b: button code
429
+ // x, y: coordinates (1-based)
430
+ // M/m: Press/Release
431
+ const b = parseInt(parts[1], 10);
432
+ const x = parseInt(parts[2], 10);
433
+ const y = parseInt(parts[3], 10);
434
+ const type = parts[4]; // M=press, m=release
435
+ key.name = 'mouse';
436
+ key.ctrl = false;
437
+ key.meta = false;
438
+ key.shift = false;
439
+ // Check for Scroll (Button 64 = Up, 65 = Down)
440
+ if (b === 64) {
441
+ key.name = 'scrollup';
442
+ key.code = 'scrollup';
443
+ }
444
+ else if (b === 65) {
445
+ key.name = 'scrolldown';
446
+ key.code = 'scrolldown';
447
+ }
448
+ // We can handle click here if needed (b=0 left, b=1 middle, b=2 right)
449
+ // but for now only scroll is requested.
450
+ }
425
451
  // Không phát ra key nếu không tìm thấy tên
426
452
  if (key.name === undefined) {
427
453
  return; // key = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cliedit",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "A lightweight, raw-mode terminal editor utility for Node.js CLI applications, with line wrapping and undo/redo support.",
5
5
  "repository": "https://github.com/CodeTease/cliedit",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  "scripts": {
22
22
  "build": "tsc",
23
23
  "prepublishOnly": "npm run build",
24
- "demo": "npm run build && cross-env NODE_ENV=development node dist/demo.js",
24
+ "demo": "npm run build && cross-env NODE_ENV=development tsx src/demo.ts",
25
25
  "test": "echo \"Error: no test specified\" && exit 1"
26
26
  },
27
27
  "keywords": [
@@ -35,10 +35,9 @@
35
35
  "author": "CodeTease",
36
36
  "license": "MIT",
37
37
  "devDependencies": {
38
- "@types/node": "^20.12.12",
39
- "cross-env": "^7.0.3",
40
- "typescript": "^5.4.5"
41
- },
42
- "dependencies": {
38
+ "@types/node": "^22",
39
+ "cross-env": "^7",
40
+ "tsx": "^4",
41
+ "typescript": "^5"
43
42
  }
44
- }
43
+ }