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 +54 -19
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/editor.d.ts +6 -2
- package/dist/editor.editing.d.ts +10 -1
- package/dist/editor.editing.js +34 -2
- package/dist/editor.io.js +1 -0
- package/dist/editor.js +30 -11
- package/dist/editor.keys.d.ts +1 -0
- package/dist/editor.keys.js +69 -8
- package/dist/editor.navigation.d.ts +4 -0
- package/dist/editor.navigation.js +91 -0
- package/dist/editor.swap.d.ts +17 -0
- package/dist/editor.swap.js +83 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +48 -10
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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,
|
package/dist/constants.d.ts
CHANGED
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;
|
package/dist/editor.editing.d.ts
CHANGED
|
@@ -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 (
|
|
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 {};
|
package/dist/editor.editing.js
CHANGED
|
@@ -44,10 +44,11 @@ function insertCharacter(char) {
|
|
|
44
44
|
this.cursorX += char.length;
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
|
-
* Inserts a soft tab (
|
|
47
|
+
* Inserts a soft tab (using configured tabSize).
|
|
48
48
|
*/
|
|
49
49
|
function insertSoftTab() {
|
|
50
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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(
|
|
102
|
-
|
|
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() {
|
package/dist/editor.keys.d.ts
CHANGED
|
@@ -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;
|
package/dist/editor.keys.js
CHANGED
|
@@ -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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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",
|