cliedit 0.1.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.
@@ -0,0 +1,67 @@
1
+ // src/editor.history.ts
2
+ /**
3
+ * Methods related to Undo/Redo operations.
4
+ */
5
+ /**
6
+ * Gets the current document state (content and cursor position).
7
+ */
8
+ function getCurrentState() {
9
+ return {
10
+ // Use JSON to deep clone the lines array
11
+ lines: JSON.parse(JSON.stringify(this.lines)),
12
+ cursorX: this.cursorX,
13
+ cursorY: this.cursorY,
14
+ };
15
+ }
16
+ /**
17
+ * Saves the current state to the history manager.
18
+ */
19
+ function saveState(initial = false) {
20
+ // Only save if content is different from the last state,
21
+ // but ALWAYS save the initial state.
22
+ this.history.saveState(this.getCurrentState());
23
+ }
24
+ /**
25
+ * Loads a document state from the history manager.
26
+ */
27
+ function loadState(state) {
28
+ this.lines = state.lines;
29
+ this.cursorX = state.cursorX;
30
+ this.cursorY = state.cursorY;
31
+ this.adjustCursorPosition();
32
+ }
33
+ /**
34
+ * Performs an undo operation.
35
+ */
36
+ function undo() {
37
+ const state = this.history.undo(this.getCurrentState());
38
+ if (state) {
39
+ this.loadState(state);
40
+ this.setDirty();
41
+ this.setStatusMessage('Undo successful');
42
+ }
43
+ else {
44
+ this.setStatusMessage('Already at oldest change');
45
+ }
46
+ }
47
+ /**
48
+ * Performs a redo operation.
49
+ */
50
+ function redo() {
51
+ const state = this.history.redo(this.getCurrentState());
52
+ if (state) {
53
+ this.loadState(state);
54
+ this.setDirty();
55
+ this.setStatusMessage('Redo successful');
56
+ }
57
+ else {
58
+ this.setStatusMessage('Already at newest change');
59
+ }
60
+ }
61
+ export const historyMethods = {
62
+ getCurrentState,
63
+ saveState,
64
+ loadState,
65
+ undo,
66
+ redo,
67
+ };
@@ -0,0 +1,17 @@
1
+ import { CliEditor } from './editor.js';
2
+ /**
3
+ * Methods related to File I/O and document state management (dirty flag).
4
+ */
5
+ /**
6
+ * Sets the document state to 'dirty' (unsaved changes).
7
+ */
8
+ declare function setDirty(this: CliEditor): void;
9
+ /**
10
+ * Saves the current document content to the file path.
11
+ */
12
+ declare function saveFile(this: CliEditor): Promise<void>;
13
+ export declare const ioMethods: {
14
+ setDirty: typeof setDirty;
15
+ saveFile: typeof saveFile;
16
+ };
17
+ export {};
@@ -0,0 +1,30 @@
1
+ // src/editor.io.ts
2
+ import { promises as fs } from 'fs';
3
+ /**
4
+ * Methods related to File I/O and document state management (dirty flag).
5
+ */
6
+ /**
7
+ * Sets the document state to 'dirty' (unsaved changes).
8
+ */
9
+ function setDirty() {
10
+ this.isDirty = true;
11
+ }
12
+ /**
13
+ * Saves the current document content to the file path.
14
+ */
15
+ async function saveFile() {
16
+ const content = this.lines.join('\n');
17
+ try {
18
+ await fs.writeFile(this.filepath, content, 'utf-8');
19
+ this.isDirty = false; // Reset dirty flag
20
+ this.quitConfirm = false; // Reset quit confirmation
21
+ this.setStatusMessage(`Saved: ${this.filepath}`, 2000);
22
+ }
23
+ catch (err) {
24
+ this.setStatusMessage(`Save Error: ${err.message}`);
25
+ }
26
+ }
27
+ export const ioMethods = {
28
+ setDirty,
29
+ saveFile,
30
+ };
package/dist/editor.js ADDED
@@ -0,0 +1,121 @@
1
+ import keypress from 'keypress';
2
+ import { ANSI } from './constants.js';
3
+ import { HistoryManager } from './history.js';
4
+ // Import all functional modules
5
+ import { editingMethods } from './editor.editing.js';
6
+ import { clipboardMethods } from './editor.clipboard.js';
7
+ import { navigationMethods } from './editor.navigation.js';
8
+ import { renderingMethods } from './editor.rendering.js';
9
+ import { searchMethods } from './editor.search.js';
10
+ import { historyMethods } from './editor.history.js';
11
+ import { ioMethods } from './editor.io.js';
12
+ import { keyHandlingMethods } from './editor.keys.js';
13
+ import { selectionMethods } from './editor.selection.js';
14
+ const DEFAULT_STATUS = 'HELP: Ctrl+S = Save & Quit | Ctrl+Q = Quit | Ctrl+C = Copy All | Ctrl+Arrow = Select';
15
+ /**
16
+ * Main editor class managing application state, TTY interaction, and rendering.
17
+ */
18
+ export class CliEditor {
19
+ constructor(initialContent, filepath) {
20
+ this.isDirty = false;
21
+ this.cursorX = 0;
22
+ this.cursorY = 0;
23
+ this.selectionAnchor = null;
24
+ this.rowOffset = 0;
25
+ this.screenRows = 0;
26
+ this.screenCols = 0;
27
+ this.gutterWidth = 5;
28
+ this.screenStartRow = 1;
29
+ this.visualRows = [];
30
+ this.mode = 'edit';
31
+ this.statusMessage = DEFAULT_STATUS;
32
+ this.statusTimeout = null;
33
+ this.isMessageCustom = false;
34
+ this.quitConfirm = false;
35
+ this.DEFAULT_STATUS = DEFAULT_STATUS;
36
+ this.searchQuery = '';
37
+ this.searchResults = [];
38
+ this.searchResultIndex = -1;
39
+ this.isCleanedUp = false;
40
+ this.resolvePromise = null;
41
+ this.rejectPromise = null;
42
+ // State flag indicating the editor is in the process of closing (prevents input/render race)
43
+ this.isExiting = false;
44
+ this.lines = initialContent.split('\n');
45
+ if (this.lines.length === 0) {
46
+ this.lines = [''];
47
+ }
48
+ this.filepath = filepath;
49
+ this.history = new HistoryManager();
50
+ this.saveState(true);
51
+ }
52
+ // --- Lifecycle Methods ---
53
+ run() {
54
+ this.setupTerminal();
55
+ this.render();
56
+ return new Promise((resolve, reject) => {
57
+ const performCleanup = (callback) => {
58
+ if (this.isCleanedUp) {
59
+ if (callback)
60
+ callback();
61
+ return;
62
+ }
63
+ // 1. Remove listeners immediately
64
+ process.stdin.removeAllListeners('keypress');
65
+ process.stdout.removeAllListeners('resize');
66
+ // 2. (FIX GHOST TUI) Write exit sequence and use callback to ensure it's written
67
+ // before Node.js fully releases the TTY.
68
+ process.stdout.write(ANSI.CLEAR_SCREEN + ANSI.MOVE_CURSOR_TOP_LEFT + ANSI.SHOW_CURSOR + ANSI.EXIT_ALTERNATE_SCREEN, () => {
69
+ // 3. Disable TTY raw mode and pause stdin after screen is cleared
70
+ process.stdin.setRawMode(false);
71
+ process.stdin.pause();
72
+ this.isCleanedUp = true;
73
+ if (callback)
74
+ callback();
75
+ });
76
+ };
77
+ this.resolvePromise = (value) => {
78
+ performCleanup(() => resolve(value));
79
+ };
80
+ this.rejectPromise = (reason) => {
81
+ performCleanup(() => reject(reason));
82
+ };
83
+ });
84
+ }
85
+ setupTerminal() {
86
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
87
+ throw new Error('Editor requires a TTY environment.');
88
+ }
89
+ this.updateScreenSize();
90
+ this.recalculateVisualRows();
91
+ // Enter alternate screen and hide cursor
92
+ process.stdout.write(ANSI.ENTER_ALTERNATE_SCREEN + ANSI.HIDE_CURSOR + ANSI.CLEAR_SCREEN);
93
+ process.stdin.setRawMode(true);
94
+ process.stdin.resume();
95
+ process.stdin.setEncoding('utf-8');
96
+ // Setup keypress listener
97
+ keypress(process.stdin);
98
+ process.stdin.on('keypress', this.handleKeypressEvent.bind(this));
99
+ process.stdout.on('resize', this.handleResize.bind(this));
100
+ }
101
+ handleResize() {
102
+ this.updateScreenSize();
103
+ this.recalculateVisualRows();
104
+ this.render();
105
+ }
106
+ updateScreenSize() {
107
+ this.screenRows = process.stdout.rows - 2;
108
+ this.screenCols = process.stdout.columns;
109
+ this.screenStartRow = 1;
110
+ }
111
+ }
112
+ // --- "Mixin" magic: Assigning all methods to the prototype ---
113
+ Object.assign(CliEditor.prototype, editingMethods);
114
+ Object.assign(CliEditor.prototype, clipboardMethods);
115
+ Object.assign(CliEditor.prototype, navigationMethods);
116
+ Object.assign(CliEditor.prototype, renderingMethods);
117
+ Object.assign(CliEditor.prototype, searchMethods);
118
+ Object.assign(CliEditor.prototype, historyMethods);
119
+ Object.assign(CliEditor.prototype, ioMethods);
120
+ Object.assign(CliEditor.prototype, keyHandlingMethods);
121
+ Object.assign(CliEditor.prototype, selectionMethods);
@@ -0,0 +1,21 @@
1
+ import type { KeypressEvent } from 'keypress';
2
+ declare module 'keypress' {
3
+ interface KeypressEvent {
4
+ name?: string;
5
+ ctrl: boolean;
6
+ meta: boolean;
7
+ shift: boolean;
8
+ sequence: string;
9
+ }
10
+ }
11
+ export type TKeyHandlingMethods = {
12
+ handleKeypressEvent: (ch: string, key: KeypressEvent) => void;
13
+ handleEditKeys: (key: string) => boolean;
14
+ handleSearchKeys: (key: string) => void;
15
+ handleCtrlQ: () => void;
16
+ handleCopy: () => Promise<void>;
17
+ handleCharacterKey: (ch: string) => void;
18
+ cutSelection: () => Promise<void>;
19
+ handleSave: () => Promise<void>;
20
+ };
21
+ export declare const keyHandlingMethods: TKeyHandlingMethods;
@@ -0,0 +1,311 @@
1
+ // src/editor.keys.ts
2
+ import { KEYS } from './constants.js';
3
+ /**
4
+ * Main router for standardized keypress events from the 'keypress' library.
5
+ */
6
+ function handleKeypressEvent(ch, key) {
7
+ // CRASH FIX: If the editor is already closing, ignore all input.
8
+ if (this.isExiting) {
9
+ return;
10
+ }
11
+ // CRASH FIX: Handle case where 'key' is undefined (a normal character key)
12
+ if (!key) {
13
+ if (ch && ch >= ' ' && ch <= '~') {
14
+ this.handleCharacterKey(ch);
15
+ this.recalculateVisualRows();
16
+ this.render();
17
+ }
18
+ return;
19
+ }
20
+ // --- From here, 'key' object is guaranteed to exist ---
21
+ let keyName = undefined;
22
+ let edited = false;
23
+ // 1. Map Control sequences (Ctrl+Arrow for selection)
24
+ if (key.ctrl) {
25
+ if (key.name === 'up')
26
+ keyName = KEYS.CTRL_ARROW_UP;
27
+ else if (key.name === 'down')
28
+ keyName = KEYS.CTRL_ARROW_DOWN;
29
+ else if (key.name === 'left')
30
+ keyName = KEYS.CTRL_ARROW_LEFT;
31
+ else if (key.name === 'right')
32
+ keyName = KEYS.CTRL_ARROW_RIGHT;
33
+ else
34
+ keyName = key.sequence; // Use sequence for Ctrl+S, Ctrl+C, etc.
35
+ }
36
+ else {
37
+ // 2. (FIXED) Map standard navigation keys (Arrow, Home, End)
38
+ if (key.name === 'up')
39
+ keyName = KEYS.ARROW_UP;
40
+ else if (key.name === 'down')
41
+ keyName = KEYS.ARROW_DOWN;
42
+ else if (key.name === 'left')
43
+ keyName = KEYS.ARROW_LEFT;
44
+ else if (key.name === 'right')
45
+ keyName = KEYS.ARROW_RIGHT;
46
+ else if (key.name === 'home')
47
+ keyName = KEYS.HOME;
48
+ else if (key.name === 'end')
49
+ keyName = KEYS.END;
50
+ else if (key.name === 'pageup')
51
+ keyName = KEYS.PAGE_UP;
52
+ else if (key.name === 'pagedown')
53
+ keyName = KEYS.PAGE_DOWN;
54
+ else if (key.name === 'delete')
55
+ keyName = KEYS.DELETE;
56
+ else if (key.name === 'backspace')
57
+ keyName = KEYS.BACKSPACE;
58
+ else if (key.name === 'return')
59
+ keyName = KEYS.ENTER;
60
+ else if (key.name === 'tab')
61
+ keyName = KEYS.TAB;
62
+ else
63
+ keyName = key.sequence; // Fallback
64
+ }
65
+ // 3. (FIXED) Handle printable characters immediately
66
+ // This was the source of the "no typing" bug.
67
+ // We must check for characters *before* routing to handleEditKeys.
68
+ if (keyName && keyName.length === 1 && keyName >= ' ' && keyName <= '~' && !key.ctrl && !key.meta) {
69
+ this.handleCharacterKey(keyName);
70
+ this.recalculateVisualRows();
71
+ this.render();
72
+ return;
73
+ }
74
+ // 4. Mode Routing (If it's not a character, it's a command)
75
+ if (this.mode === 'search') {
76
+ this.handleSearchKeys(keyName || ch);
77
+ }
78
+ else {
79
+ // 5. Handle Selection Keys (Ctrl+Arrow)
80
+ switch (keyName) {
81
+ case KEYS.CTRL_ARROW_UP:
82
+ this.startOrUpdateSelection();
83
+ this.moveCursorVisually(-1);
84
+ this.render();
85
+ return;
86
+ case KEYS.CTRL_ARROW_DOWN:
87
+ this.startOrUpdateSelection();
88
+ this.moveCursorVisually(1);
89
+ this.render();
90
+ return;
91
+ case KEYS.CTRL_ARROW_LEFT:
92
+ this.startOrUpdateSelection();
93
+ this.moveCursorLogically(-1);
94
+ this.render();
95
+ return;
96
+ case KEYS.CTRL_ARROW_RIGHT:
97
+ this.startOrUpdateSelection();
98
+ this.moveCursorLogically(1);
99
+ this.render();
100
+ return;
101
+ }
102
+ // 6. Handle all other command keys (Editing/Commands)
103
+ edited = this.handleEditKeys(keyName || ch);
104
+ }
105
+ // 7. State Update and Render
106
+ if (edited) {
107
+ this.saveState();
108
+ this.recalculateVisualRows();
109
+ }
110
+ if (!this.isExiting) {
111
+ this.render();
112
+ }
113
+ }
114
+ /**
115
+ * Handles all command keys in 'edit' mode.
116
+ * Returns true if content was modified.
117
+ */
118
+ function handleEditKeys(key) {
119
+ // (FIXED) Removed the guard clause that was blocking typing.
120
+ // if (key.length === 1 && key >= ' ' && key <= '~') {
121
+ // return false;
122
+ // }
123
+ // Cancel selection on normal navigation
124
+ const isNavigation = [
125
+ KEYS.ARROW_UP, KEYS.ARROW_DOWN, KEYS.ARROW_LEFT, KEYS.ARROW_RIGHT,
126
+ KEYS.HOME, KEYS.END, KEYS.PAGE_UP, KEYS.PAGE_DOWN
127
+ ].includes(key);
128
+ if (isNavigation) {
129
+ this.cancelSelection();
130
+ if (this.isMessageCustom) {
131
+ this.setStatusMessage(this.DEFAULT_STATUS, 0);
132
+ }
133
+ }
134
+ // Commands that return Promises must be wrapped in a sync call here
135
+ switch (key) {
136
+ // --- Exit / Save ---
137
+ case KEYS.CTRL_Q:
138
+ this.handleCtrlQ();
139
+ return false;
140
+ case KEYS.CTRL_S:
141
+ this.handleSave();
142
+ return false;
143
+ case KEYS.CTRL_C:
144
+ this.handleCopy();
145
+ return false;
146
+ // --- Navigation ---
147
+ case KEYS.ARROW_UP:
148
+ this.moveCursorVisually(-1);
149
+ return false;
150
+ case KEYS.ARROW_DOWN:
151
+ this.moveCursorVisually(1);
152
+ return false;
153
+ case KEYS.ARROW_LEFT:
154
+ this.moveCursorLogically(-1);
155
+ return false;
156
+ case KEYS.ARROW_RIGHT:
157
+ this.moveCursorLogically(1);
158
+ return false;
159
+ case KEYS.HOME:
160
+ this.cursorX = this.findVisualRowStart();
161
+ return false;
162
+ case KEYS.END:
163
+ this.cursorX = this.findVisualRowEnd();
164
+ return false;
165
+ case KEYS.PAGE_UP:
166
+ this.moveCursorVisually(-this.screenRows);
167
+ return false;
168
+ case KEYS.PAGE_DOWN:
169
+ this.moveCursorVisually(this.screenRows);
170
+ return false;
171
+ // --- Editing ---
172
+ case KEYS.ENTER:
173
+ this.insertNewLine();
174
+ return true;
175
+ case KEYS.BACKSPACE:
176
+ if (this.selectionAnchor)
177
+ this.deleteSelectedText();
178
+ else
179
+ this.deleteBackward();
180
+ return true;
181
+ case KEYS.DELETE:
182
+ if (this.selectionAnchor)
183
+ this.deleteSelectedText();
184
+ else
185
+ this.deleteForward();
186
+ return true;
187
+ case KEYS.TAB:
188
+ this.insertSoftTab();
189
+ return true;
190
+ // --- Search & History ---
191
+ case KEYS.CTRL_W:
192
+ this.enterSearchMode();
193
+ return false;
194
+ case KEYS.CTRL_G:
195
+ this.findNext();
196
+ return false;
197
+ case KEYS.CTRL_Z:
198
+ this.undo();
199
+ return true;
200
+ case KEYS.CTRL_Y:
201
+ this.redo();
202
+ return true;
203
+ // --- Clipboard ---
204
+ case KEYS.CTRL_K: // Cut Line (Traditional)
205
+ this.cutLine();
206
+ return true;
207
+ case KEYS.CTRL_U: // Paste Line (Traditional)
208
+ this.pasteLine();
209
+ return true;
210
+ case KEYS.CTRL_X: // Cut Selection
211
+ this.cutSelection(); // Synchronous wrapper for cutSelectionAsync
212
+ return true;
213
+ case KEYS.CTRL_V: // Paste Selection
214
+ this.pasteSelection();
215
+ return true;
216
+ default:
217
+ return false;
218
+ }
219
+ }
220
+ /**
221
+ * Handles insertion of a character, deleting selection first if it exists.
222
+ */
223
+ function handleCharacterKey(ch) {
224
+ if (this.selectionAnchor) {
225
+ this.deleteSelectedText();
226
+ }
227
+ this.insertCharacter(ch);
228
+ this.setDirty();
229
+ }
230
+ /**
231
+ * Handles Ctrl+Q (Quit) sequence.
232
+ */
233
+ function handleCtrlQ() {
234
+ if (this.isDirty && !this.quitConfirm) {
235
+ this.quitConfirm = true;
236
+ this.setStatusMessage('Warning: Unsaved changes! Press Ctrl+Q again to quit.');
237
+ setTimeout(() => { this.quitConfirm = false; }, 3000);
238
+ return;
239
+ }
240
+ this.isExiting = true;
241
+ this.resolvePromise?.({ saved: false, content: this.lines.join('\n') });
242
+ }
243
+ /**
244
+ * Handles Ctrl+C (Copy Selection or All) sequence.
245
+ */
246
+ async function handleCopy() {
247
+ let textToCopy = '';
248
+ if (this.selectionAnchor) {
249
+ textToCopy = this.getSelectedText();
250
+ this.setStatusMessage('Selection copied!', 1000);
251
+ }
252
+ else {
253
+ // Copy entire file content if nothing is selected (clean copy)
254
+ textToCopy = this.lines.join('\n');
255
+ this.setStatusMessage('Copied all text!', 1000);
256
+ }
257
+ await this.setClipboard(textToCopy);
258
+ }
259
+ /**
260
+ * Handles synchronous call for cutting selection (used by Ctrl+X).
261
+ */
262
+ async function cutSelection() {
263
+ await this.cutSelectionAsync();
264
+ }
265
+ /**
266
+ * Helper function to handle the final save and exit sequence (used by Ctrl+S).
267
+ */
268
+ async function handleSave() {
269
+ await this.saveFile(); // Save file (sets isDirty=false)
270
+ // Only resolve if not already exiting
271
+ if (!this.isExiting) {
272
+ this.isExiting = true;
273
+ this.resolvePromise?.({ saved: true, content: this.lines.join('\n') });
274
+ }
275
+ }
276
+ /**
277
+ * Handles Search Mode input keys.
278
+ */
279
+ function handleSearchKeys(key) {
280
+ switch (key) {
281
+ case KEYS.ENTER:
282
+ this.executeSearch();
283
+ this.mode = 'edit';
284
+ this.findNext();
285
+ break;
286
+ case KEYS.ESCAPE:
287
+ case KEYS.CTRL_C:
288
+ case KEYS.CTRL_Q:
289
+ this.mode = 'edit';
290
+ this.searchQuery = '';
291
+ this.setStatusMessage('Search cancelled');
292
+ break;
293
+ case KEYS.BACKSPACE:
294
+ this.searchQuery = this.searchQuery.slice(0, -1);
295
+ break;
296
+ default:
297
+ if (key.length === 1 && key >= ' ' && key <= '~') {
298
+ this.searchQuery += key;
299
+ }
300
+ }
301
+ }
302
+ export const keyHandlingMethods = {
303
+ handleKeypressEvent,
304
+ handleEditKeys,
305
+ handleSearchKeys,
306
+ handleCtrlQ,
307
+ handleCopy,
308
+ handleCharacterKey,
309
+ cutSelection,
310
+ handleSave,
311
+ };
@@ -0,0 +1,42 @@
1
+ import { CliEditor } from './editor.js';
2
+ /**
3
+ * Methods related to cursor movement and viewport scrolling.
4
+ */
5
+ /**
6
+ * Finds the index of the visual row that currently contains the cursor.
7
+ */
8
+ declare function findCurrentVisualRowIndex(this: CliEditor): number;
9
+ /**
10
+ * Moves the cursor one position left or right (logically, wrapping lines).
11
+ */
12
+ declare function moveCursorLogically(this: CliEditor, dx: number): void;
13
+ /**
14
+ * Moves the cursor up or down by visual rows (dy).
15
+ */
16
+ declare function moveCursorVisually(this: CliEditor, dy: number): void;
17
+ /**
18
+ * Finds the start of the current visual line (Home key behavior).
19
+ */
20
+ declare function findVisualRowStart(this: CliEditor): number;
21
+ /**
22
+ * Finds the end of the current visual line (End key behavior).
23
+ */
24
+ declare function findVisualRowEnd(this: CliEditor): number;
25
+ /**
26
+ * Clamps the cursor position to valid coordinates and ensures it stays within line bounds.
27
+ */
28
+ declare function adjustCursorPosition(this: CliEditor): void;
29
+ /**
30
+ * Scrolls the viewport to keep the cursor visible.
31
+ */
32
+ declare function scroll(this: CliEditor): void;
33
+ export declare const navigationMethods: {
34
+ findCurrentVisualRowIndex: typeof findCurrentVisualRowIndex;
35
+ moveCursorLogically: typeof moveCursorLogically;
36
+ moveCursorVisually: typeof moveCursorVisually;
37
+ findVisualRowStart: typeof findVisualRowStart;
38
+ findVisualRowEnd: typeof findVisualRowEnd;
39
+ adjustCursorPosition: typeof adjustCursorPosition;
40
+ scroll: typeof scroll;
41
+ };
42
+ export {};