cliedit 0.1.2 → 0.2.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
@@ -4,7 +4,7 @@ A lightweight, zero-dependency, raw-mode terminal editor component for Node.js.
4
4
 
5
5
  `cliedit` is designed to be imported into your own CLI application to provide a full-featured, TTY-based text editing experience. It's perfect for applications that need to ask the user for multi-line input, edit configuration files, or write commit messages.
6
6
 
7
- It includes line wrapping, visual navigation, undo/redo, text selection, and clipboard support.
7
+ It includes line wrapping, visual navigation, smart auto-indentation, undo/redo, text selection, Find/Replace, and cross-platform clipboard support.
8
8
 
9
9
  ## Features
10
10
 
@@ -13,60 +13,94 @@ It includes line wrapping, visual navigation, undo/redo, text selection, and cli
13
13
  - **Visual Navigation:** `Up`/`Down` arrows move by visual rows, not logical lines.
14
14
  - **Undo/Redo:** `Ctrl+Z` / `Ctrl+Y` for persistent history.
15
15
  - **Text Selection:** `Ctrl+Arrow` keys to select text.
16
- - **Clipboard Support:** `Ctrl+C` (Copy), `Ctrl+X` (Cut), `Ctrl+V` (Paste) for system clipboard (macOS/Windows).
16
+ - **Clipboard Support:** `Ctrl+C` (Copy), `Ctrl+X` (Cut), `Ctrl+V` (Paste) for system clipboard (macOS, Windows, **and Linux** via `xclip`).
17
17
  - **File I/O:** Loads from and saves to the filesystem.
18
- - **Search:** `Ctrl+W` to find text.
18
+ - **Search & Replace:** `Ctrl+W` to find text, `Ctrl+R` to find and replace interactively.
19
+ - **Go to Line:** `Ctrl+L` to quickly jump to a specific line number.
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.
19
24
 
20
25
  ## Installation
21
26
  ```bash
22
27
  npm install cliedit
23
- ```
28
+ ````
24
29
 
25
30
  ## Usage
26
31
 
27
32
  The package exports an `async` function `openEditor` that returns a `Promise`. The promise resolves when the user quits the editor.
33
+
28
34
  ```javascript
29
35
  import { openEditor } from 'cliedit';
30
36
  import path from 'path';
31
37
 
32
38
  async function getCommitMessage() {
33
39
  const tempFile = path.resolve(process.cwd(), 'COMMIT_MSG.txt');
34
- console.log('Opening editor for commit message...');
40
+
41
+ // Example with custom options
42
+ const options = {
43
+ tabSize: 2,
44
+ gutterWidth: 3
45
+ };
35
46
 
36
47
  try {
37
- const result = await openEditor(tempFile);
38
-
39
- // Give the terminal a moment to restore
40
- await new Promise(res => setTimeout(res, 50));
48
+ const result = await openEditor(tempFile, options);
41
49
 
42
50
  if (result.saved) {
43
- console.log('Message saved!');
44
- console.log('---------------------');
45
- console.log(result.content);
46
- console.log('---------------------');
51
+ console.log('Message saved:', result.content);
47
52
  } else {
48
53
  console.log('Editor quit without saving.');
49
54
  }
50
55
  } catch (err) {
51
- console.error('Editor failed to start:', err);
56
+ console.error('Editor failed:', err);
52
57
  }
53
58
  }
54
59
 
55
60
  getCommitMessage();
56
61
  ```
57
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
+
58
75
  ## Public API
59
76
 
60
- `openEditor(filepath: string)`
77
+ `openEditor(filepath: string, options?: EditorOptions)`
61
78
 
62
- Opens the editor for the specified file. If the file doesn't exist, it will be created upon saving.
63
- - **Returns:** `Promise<{ saved: boolean; content: string }>`
64
- * `saved`: `true` if the user saved (Ctrl+S), `false` otherwise (Ctrl+Q).
65
- * `content`: The final content of the file as a string.
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.
93
+
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.
95
+
96
+ - **Returns:** `Promise<{ saved: boolean; content: string }>`
97
+ * `saved`: `true` if the user saved (Ctrl+S), `false` otherwise (Ctrl+Q).
98
+ * `content`: The final content of the file as a string.
66
99
 
67
100
  `CliEditor`
68
101
 
69
102
  The main editor class. You can import this directly if you need to extend or instantiate the editor with custom logic.
103
+
70
104
  ```javascript
71
105
  import { CliEditor } from 'cliedit';
72
106
  ```
@@ -74,6 +108,7 @@ import { CliEditor } from 'cliedit';
74
108
  ### Types
75
109
 
76
110
  Key types are also exported for convenience:
111
+
77
112
  ```javascript
78
113
  import type {
79
114
  DocumentState,
@@ -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;
@@ -52,14 +54,16 @@ export declare class CliEditor {
52
54
  }[];
53
55
  searchResultIndex: number;
54
56
  history: HistoryManager;
57
+ swapManager: SwapManager;
55
58
  isCleanedUp: boolean;
56
59
  resolvePromise: ((value: {
57
60
  saved: boolean;
58
61
  content: string;
59
62
  }) => void) | null;
60
63
  rejectPromise: ((reason?: any) => void) | null;
64
+ inputStream: any;
61
65
  isExiting: boolean;
62
- constructor(initialContent: string, filepath: string);
66
+ constructor(initialContent: string, filepath: string, options?: EditorOptions);
63
67
  run(): Promise<{
64
68
  saved: boolean;
65
69
  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
  /**
@@ -29,6 +29,14 @@ declare function deleteBackward(this: CliEditor): void;
29
29
  * Deletes the character after the cursor, or joins the current line with the next one.
30
30
  */
31
31
  declare function deleteForward(this: CliEditor): void;
32
+ /**
33
+ * Handles auto-pairing of brackets and quotes.
34
+ * If text is selected, it wraps the selection.
35
+ * Otherwise, it inserts the pair and places the cursor in the middle.
36
+ * @param openChar The opening character that was typed (e.g., '(', '[', '{').
37
+ * @param closeChar The corresponding closing character (e.g., ')', ']', '}').
38
+ */
39
+ declare function handleAutoPair(this: CliEditor, openChar: string, closeChar: string): void;
32
40
  export declare const editingMethods: {
33
41
  insertContentAtCursor: typeof insertContentAtCursor;
34
42
  insertCharacter: typeof insertCharacter;
@@ -36,5 +44,6 @@ export declare const editingMethods: {
36
44
  insertNewLine: typeof insertNewLine;
37
45
  deleteBackward: typeof deleteBackward;
38
46
  deleteForward: typeof deleteForward;
47
+ handleAutoPair: typeof handleAutoPair;
39
48
  };
40
49
  export {};
@@ -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.
@@ -110,6 +111,36 @@ function deleteForward() {
110
111
  }
111
112
  this.setDirty();
112
113
  }
114
+ /**
115
+ * Handles auto-pairing of brackets and quotes.
116
+ * If text is selected, it wraps the selection.
117
+ * Otherwise, it inserts the pair and places the cursor in the middle.
118
+ * @param openChar The opening character that was typed (e.g., '(', '[', '{').
119
+ * @param closeChar The corresponding closing character (e.g., ')', ']', '}').
120
+ */
121
+ function handleAutoPair(openChar, closeChar) {
122
+ if (this.selectionAnchor) {
123
+ // There is a selection, so we need to wrap it.
124
+ const selection = this.getNormalizedSelection();
125
+ if (!selection)
126
+ return; // Should not happen if anchor exists, but good practice
127
+ const selectedText = this.getSelectedText();
128
+ // The deleteSelectedText() function automatically moves the cursor to the start
129
+ // of the selection, so we don't need to set it manually.
130
+ this.deleteSelectedText();
131
+ // Wrap the original selected text
132
+ const wrappedText = openChar + selectedText + closeChar;
133
+ this.insertContentAtCursor(wrappedText.split('\n'));
134
+ // The selection is already cancelled by deleteSelectedText().
135
+ }
136
+ else {
137
+ // No selection, just insert the opening and closing characters
138
+ this.insertCharacter(openChar + closeChar);
139
+ // Move cursor back one position to be in between the pair
140
+ this.cursorX--;
141
+ }
142
+ this.setDirty();
143
+ }
113
144
  export const editingMethods = {
114
145
  insertContentAtCursor,
115
146
  insertCharacter,
@@ -117,4 +148,5 @@ export const editingMethods = {
117
148
  insertNewLine,
118
149
  deleteBackward,
119
150
  deleteForward,
151
+ handleAutoPair,
120
152
  };
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';
@@ -50,29 +52,38 @@ export class CliEditor {
50
52
  this.lines = [''];
51
53
  }
52
54
  this.filepath = filepath;
55
+ this.gutterWidth = options.gutterWidth ?? 5;
56
+ this.tabSize = options.tabSize ?? 4;
57
+ this.inputStream = options.inputStream || process.stdin;
53
58
  this.history = new HistoryManager();
54
59
  this.saveState(true);
60
+ // Initialize SwapManager
61
+ this.swapManager = new SwapManager(this.filepath, () => this.lines.join('\n'));
55
62
  }
56
63
  // --- Lifecycle Methods ---
57
64
  run() {
58
65
  this.setupTerminal();
59
66
  this.render();
67
+ this.swapManager.start();
60
68
  return new Promise((resolve, reject) => {
61
69
  const performCleanup = (callback) => {
70
+ this.swapManager.stop(); // Stop swap interval
62
71
  if (this.isCleanedUp) {
63
72
  if (callback)
64
73
  callback();
65
74
  return;
66
75
  }
67
76
  // 1. Remove listeners immediately
68
- process.stdin.removeAllListeners('keypress');
77
+ this.inputStream.removeAllListeners('keypress');
69
78
  process.stdout.removeAllListeners('resize');
70
79
  // 2. (FIX GHOST TUI) Write exit sequence and use callback to ensure it's written
71
80
  // before Node.js fully releases the TTY.
72
81
  process.stdout.write(ANSI.CLEAR_SCREEN + ANSI.MOVE_CURSOR_TOP_LEFT + ANSI.SHOW_CURSOR + ANSI.EXIT_ALTERNATE_SCREEN, () => {
73
82
  // 3. Disable TTY raw mode and pause stdin after screen is cleared
74
- process.stdin.setRawMode(false);
75
- process.stdin.pause();
83
+ if (this.inputStream.setRawMode) {
84
+ this.inputStream.setRawMode(false);
85
+ }
86
+ this.inputStream.pause();
76
87
  this.isCleanedUp = true;
77
88
  if (callback)
78
89
  callback();
@@ -87,19 +98,27 @@ export class CliEditor {
87
98
  });
88
99
  }
89
100
  setupTerminal() {
90
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
91
- throw new Error('Editor requires a TTY environment.');
101
+ // If we are using a custom inputStream (re-opened TTY), it might be a ReadStream which is TTY.
102
+ // Check if it is TTY
103
+ if (!this.inputStream.isTTY && !process.stdin.isTTY) {
104
+ // If both are not TTY, we have a problem.
105
+ // But if inputStream is our manually opened TTY, isTTY should be true.
106
+ }
107
+ if (!process.stdout.isTTY) {
108
+ throw new Error('Editor requires a TTY environment (stdout).');
92
109
  }
93
110
  this.updateScreenSize();
94
111
  this.recalculateVisualRows();
95
112
  // Enter alternate screen and hide cursor
96
113
  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');
114
+ if (this.inputStream.setRawMode) {
115
+ this.inputStream.setRawMode(true);
116
+ }
117
+ this.inputStream.resume();
118
+ this.inputStream.setEncoding('utf-8');
100
119
  // Setup keypress listener
101
- keypress(process.stdin);
102
- process.stdin.on('keypress', this.handleKeypressEvent.bind(this));
120
+ keypress(this.inputStream);
121
+ this.inputStream.on('keypress', this.handleKeypressEvent.bind(this));
103
122
  process.stdout.on('resize', this.handleResize.bind(this));
104
123
  }
105
124
  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;
@@ -1,5 +1,12 @@
1
1
  // src/editor.keys.ts
2
2
  import { KEYS } from './constants.js';
3
+ const PAIR_MAP = {
4
+ '(': ')',
5
+ '[': ']',
6
+ '{': '}',
7
+ "'": "'",
8
+ '"': '"',
9
+ };
3
10
  /**
4
11
  * Main router for standardized keypress events from the 'keypress' library.
5
12
  */
@@ -74,6 +81,10 @@ function handleKeypressEvent(ch, key) {
74
81
  keyName = KEYS.ENTER;
75
82
  else if (key.name === 'tab')
76
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';
77
88
  else
78
89
  keyName = key.sequence;
79
90
  }
@@ -118,6 +129,12 @@ function handleKeypressEvent(ch, key) {
118
129
  this.render(); // Render cuối cùng (với visual rows đã được cập nhật nếu cần)
119
130
  }
120
131
  }
132
+ function handleAltArrows(keyName) {
133
+ if (keyName === 'ALT_LEFT')
134
+ this.moveCursorByWord('left');
135
+ else if (keyName === 'ALT_RIGHT')
136
+ this.moveCursorByWord('right');
137
+ }
121
138
  /**
122
139
  * Handles all command keys in 'edit' mode.
123
140
  * Returns true if content was modified.
@@ -175,10 +192,24 @@ function handleEditKeys(key) {
175
192
  this.insertNewLine();
176
193
  return true;
177
194
  case KEYS.BACKSPACE:
178
- if (this.selectionAnchor)
179
- this.deleteSelectedText();
180
- else
181
- this.deleteBackward();
195
+ // Handle auto-pair deletion
196
+ const line = this.lines[this.cursorY] || '';
197
+ const charBefore = line[this.cursorX - 1];
198
+ const charAfter = line[this.cursorX];
199
+ if (!this.selectionAnchor &&
200
+ charBefore && charAfter &&
201
+ PAIR_MAP[charBefore] === charAfter) {
202
+ // Delete both characters of the pair
203
+ this.lines[this.cursorY] = line.slice(0, this.cursorX - 1) + line.slice(this.cursorX + 1);
204
+ this.cursorX--; // Move cursor back
205
+ this.setDirty();
206
+ }
207
+ else {
208
+ if (this.selectionAnchor)
209
+ this.deleteSelectedText();
210
+ else
211
+ this.deleteBackward();
212
+ }
182
213
  return true;
183
214
  case KEYS.DELETE:
184
215
  if (this.selectionAnchor)
@@ -202,6 +233,20 @@ function handleEditKeys(key) {
202
233
  case KEYS.CTRL_G:
203
234
  this.findNext();
204
235
  return false;
236
+ // --- Smart Navigation ---
237
+ case 'ALT_LEFT':
238
+ this.moveCursorByWord('left');
239
+ return false;
240
+ case 'ALT_RIGHT':
241
+ this.moveCursorByWord('right');
242
+ return false;
243
+ case KEYS.CTRL_M: // Or any key for Bracket Match. Ctrl+M is technically Enter in some terms but if available...
244
+ // Let's use Ctrl+B (Bracket) if not taken? Ctrl+B is often bold, but here it's CLI.
245
+ // Or just check if key is match bracket key.
246
+ // Let's try to map a specific key or use Meta.
247
+ // For now, let's use Ctrl+B?
248
+ this.matchBracket();
249
+ return false;
205
250
  // ***** SỬA LỖI VISUAL *****
206
251
  // Sau khi undo/redo, chúng ta PHẢI tính toán lại visual rows
207
252
  case KEYS.CTRL_Z:
@@ -238,11 +283,26 @@ function handleEditKeys(key) {
238
283
  * Handles insertion of a character, deleting selection first if it exists.
239
284
  */
240
285
  function handleCharacterKey(ch) {
241
- if (this.selectionAnchor) {
242
- this.deleteSelectedText();
286
+ const line = this.lines[this.cursorY] || '';
287
+ const charAfter = line[this.cursorX];
288
+ // If user types a closing character and it's what we expect, just move the cursor.
289
+ if (!this.selectionAnchor &&
290
+ (ch === ')' || ch === ']' || ch === '}' || ch === "'" || ch === '"') &&
291
+ charAfter === ch) {
292
+ this.cursorX++;
293
+ return;
294
+ }
295
+ const closeChar = PAIR_MAP[ch];
296
+ if (closeChar) {
297
+ this.handleAutoPair(ch, closeChar);
298
+ }
299
+ else {
300
+ if (this.selectionAnchor) {
301
+ this.deleteSelectedText();
302
+ }
303
+ this.insertCharacter(ch);
304
+ this.setDirty();
243
305
  }
244
- this.insertCharacter(ch);
245
- this.setDirty();
246
306
  }
247
307
  /**
248
308
  * Handles Ctrl+Q (Quit) sequence.
@@ -429,4 +489,5 @@ export const keyHandlingMethods = {
429
489
  handleCharacterKey,
430
490
  cutSelection,
431
491
  handleSave,
492
+ handleAltArrows,
432
493
  };
@@ -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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cliedit",
3
- "version": "0.1.2",
3
+ "version": "0.2.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",