saveinme 1.3.4 โ†’ 2.0.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/src/tui.js ADDED
@@ -0,0 +1,336 @@
1
+ import readline from 'readline';
2
+ import chalk from 'chalk';
3
+ import { getAllNotes, togglePin, deleteNoteById, getNoteById } from './store.js';
4
+ import { showNote, showError, showSuccess } from './display.js';
5
+ import { openEditor } from './editor.js';
6
+ import { triggerAutoSync } from './sync.js';
7
+
8
+ let notes = [];
9
+ let selectedIndex = 0;
10
+ let searchQuery = '';
11
+ let searchMode = false;
12
+ let screenHeight = process.stdout.rows;
13
+ let screenWidth = process.stdout.columns;
14
+ let deleteConfirmId = null; // Stored note ID when asking for delete confirmation
15
+ let tuiSuspended = false;
16
+
17
+ /**
18
+ * Wraps text into lines of maximum width.
19
+ */
20
+ function wrapText(text, limit) {
21
+ const lines = [];
22
+ const rawLines = text.split('\n');
23
+ for (const line of rawLines) {
24
+ if (line.length <= limit) {
25
+ lines.push(line);
26
+ } else {
27
+ let current = line;
28
+ while (current.length > limit) {
29
+ let spaceIdx = current.lastIndexOf(' ', limit);
30
+ if (spaceIdx === -1 || spaceIdx === 0) spaceIdx = limit;
31
+ lines.push(current.slice(0, spaceIdx));
32
+ current = current.slice(spaceIdx).trim();
33
+ }
34
+ if (current) lines.push(current);
35
+ }
36
+ }
37
+ return lines;
38
+ }
39
+
40
+ /**
41
+ * Loads and sorts notes, applying search filters if active.
42
+ */
43
+ function loadNotes() {
44
+ const allNotes = getAllNotes();
45
+ // Sort: pinned first, then by updatedAt desc
46
+ const sorted = allNotes.sort((a, b) => {
47
+ if (a.pinned && !b.pinned) return -1;
48
+ if (!a.pinned && b.pinned) return 1;
49
+ return new Date(b.updatedAt) - new Date(a.updatedAt);
50
+ });
51
+
52
+ if (searchQuery.trim()) {
53
+ const q = searchQuery.toLowerCase();
54
+ notes = sorted.filter(n =>
55
+ n.title.toLowerCase().includes(q) ||
56
+ n.content.toLowerCase().includes(q) ||
57
+ (n.tags ?? []).some(t => t.toLowerCase().includes(q)) ||
58
+ (n.category ?? '').toLowerCase().includes(q)
59
+ );
60
+ } else {
61
+ notes = sorted;
62
+ }
63
+
64
+ // Adjust selected index to stay in bounds
65
+ if (selectedIndex >= notes.length) {
66
+ selectedIndex = Math.max(0, notes.length - 1);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Renders the dashboard screen.
72
+ */
73
+ function draw() {
74
+ screenWidth = process.stdout.columns;
75
+ screenHeight = process.stdout.rows;
76
+
77
+ // Clear screen and move cursor to top-left
78
+ process.stdout.write('\x1b[2J\x1b[H');
79
+
80
+ // Draw Header
81
+ const header = ` ๐Ÿ’พ saveinme Dashboard [v2.0.0] ยท ${notes.length} note(s) shown`;
82
+ console.log(chalk.bgCyan.black.bold(header.padEnd(screenWidth)));
83
+
84
+ const leftWidth = Math.max(30, Math.min(50, Math.floor(screenWidth * 0.35)));
85
+ const rightWidth = screenWidth - leftWidth - 3;
86
+ const contentHeight = screenHeight - 3; // Subtract header and bottom bar
87
+
88
+ const selectedNote = notes[selectedIndex];
89
+ let rightLines = [];
90
+
91
+ if (selectedNote) {
92
+ // Generate right pane preview lines
93
+ rightLines.push(chalk.bold.yellow(selectedNote.title));
94
+ const meta = [
95
+ selectedNote.category ? `๐Ÿ“ ${selectedNote.category}` : null,
96
+ (selectedNote.tags ?? []).length ? selectedNote.tags.map(t => `#${t}`).join(' ') : null,
97
+ selectedNote.pinned ? '๐Ÿ“Œ pinned' : null,
98
+ ].filter(Boolean).join(' ยท ');
99
+ if (meta) rightLines.push(chalk.dim(meta));
100
+ rightLines.push(chalk.dim(`Words: ${selectedNote.wordCount ?? 0} ยท Updated: ${new Date(selectedNote.updatedAt).toLocaleDateString()}`));
101
+ rightLines.push(chalk.gray('โ”€'.repeat(rightWidth)));
102
+ rightLines.push('');
103
+
104
+ const contentWrapped = wrapText(selectedNote.content, rightWidth);
105
+ rightLines.push(...contentWrapped);
106
+ } else {
107
+ rightLines.push(chalk.dim('No notes found.'));
108
+ }
109
+
110
+ // Draw content rows
111
+ for (let r = 0; r < contentHeight; r++) {
112
+ // Left Pane (Notes list)
113
+ let leftContent = '';
114
+ if (r < notes.length) {
115
+ const note = notes[r];
116
+ const pin = note.pinned ? '๐Ÿ“Œ' : ' ';
117
+ const label = `${pin} ${note.title}`;
118
+ const truncated = label.slice(0, leftWidth - 2).padEnd(leftWidth - 2);
119
+
120
+ if (r === selectedIndex) {
121
+ leftContent = chalk.bgBlue.white.bold(` > ${truncated} `);
122
+ } else {
123
+ leftContent = ` ${truncated} `;
124
+ }
125
+ } else {
126
+ leftContent = ' '.repeat(leftWidth + 2);
127
+ }
128
+
129
+ // Right Pane
130
+ let rightContent = '';
131
+ if (r < rightLines.length) {
132
+ rightContent = rightLines[r];
133
+ }
134
+
135
+ // Output combined line
136
+ const border = chalk.cyan('โ”‚');
137
+ console.log(`${leftContent}${border} ${rightContent}`);
138
+ }
139
+
140
+ // Draw Bottom Bar
141
+ let bottomBar = '';
142
+ if (deleteConfirmId) {
143
+ bottomBar = chalk.bgRed.white.bold(` โš ๏ธ Are you sure you want to delete this note? (y/n) `);
144
+ } else if (searchMode) {
145
+ bottomBar = chalk.bgYellow.black.bold(` ๐Ÿ” Search: ${searchQuery}_ `);
146
+ } else {
147
+ bottomBar = chalk.bgGray.black(
148
+ ` [โ‡…] Nav ยท [Enter] Edit ยท [c] Create ยท [p] Pin ยท [d] Delete ยท [/] Search ยท [q] Quit`
149
+ );
150
+ }
151
+ process.stdout.write(bottomBar.padEnd(screenWidth));
152
+ }
153
+
154
+ /**
155
+ * Cleans up terminal settings and exits raw mode.
156
+ */
157
+ function exitTUI() {
158
+ process.stdin.setRawMode(false);
159
+ process.stdin.pause();
160
+ // Clear screen and show cursor
161
+ process.stdout.write('\x1b[2J\x1b[H\x1b[?25h');
162
+ process.exit(0);
163
+ }
164
+
165
+ /**
166
+ * Suspends TUI key listeners to run an interactive terminal edit/create session.
167
+ */
168
+ async function runInteractiveCommand(callback) {
169
+ tuiSuspended = true;
170
+ // Detach keypress listener to avoid conflicts with inquirer prompts
171
+ process.stdin.removeListener('keypress', handleKeypress);
172
+
173
+ // Exit raw mode and show cursor
174
+ process.stdin.setRawMode(false);
175
+ process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
176
+
177
+ try {
178
+ await callback();
179
+ } catch (err) {
180
+ console.error(err);
181
+ }
182
+
183
+ // Restore raw mode and hide cursor
184
+ process.stdin.setRawMode(true);
185
+ process.stdin.resume();
186
+ process.stdin.on('keypress', handleKeypress);
187
+ process.stdout.write('\x1b[?25l');
188
+ tuiSuspended = false;
189
+
190
+ loadNotes();
191
+ draw();
192
+ }
193
+
194
+ /**
195
+ * Handle keypress events for the TUI.
196
+ */
197
+ async function handleKeypress(str, key) {
198
+ if (tuiSuspended) return;
199
+ if (!key) return;
200
+
201
+ // Graceful exit on Ctrl+C
202
+ if (key.ctrl && key.name === 'c') {
203
+ exitTUI();
204
+ return;
205
+ }
206
+
207
+ // --- Mode: Delete Confirmation ---
208
+ if (deleteConfirmId) {
209
+ if (key.name === 'y') {
210
+ deleteNoteById(deleteConfirmId);
211
+ triggerAutoSync();
212
+ deleteConfirmId = null;
213
+ loadNotes();
214
+ draw();
215
+ } else if (key.name === 'n' || key.name === 'escape') {
216
+ deleteConfirmId = null;
217
+ draw();
218
+ }
219
+ return;
220
+ }
221
+
222
+ // --- Mode: Search Input ---
223
+ if (searchMode) {
224
+ if (key.name === 'escape') {
225
+ searchQuery = '';
226
+ searchMode = false;
227
+ loadNotes();
228
+ draw();
229
+ } else if (key.name === 'enter' || key.name === 'return') {
230
+ searchMode = false;
231
+ draw();
232
+ } else if (key.name === 'backspace') {
233
+ searchQuery = searchQuery.slice(0, -1);
234
+ loadNotes();
235
+ draw();
236
+ } else if (str && !key.ctrl && !key.meta) {
237
+ searchQuery += str;
238
+ loadNotes();
239
+ draw();
240
+ }
241
+ return;
242
+ }
243
+
244
+ // --- Mode: Normal Navigation ---
245
+ switch (key.name) {
246
+ case 'up':
247
+ case 'k':
248
+ if (selectedIndex > 0) {
249
+ selectedIndex--;
250
+ draw();
251
+ }
252
+ break;
253
+
254
+ case 'down':
255
+ case 'j':
256
+ if (selectedIndex < notes.length - 1) {
257
+ selectedIndex++;
258
+ draw();
259
+ }
260
+ break;
261
+
262
+ case 'p': {
263
+ const note = notes[selectedIndex];
264
+ if (note) {
265
+ togglePin(note.id);
266
+ triggerAutoSync();
267
+ loadNotes();
268
+ draw();
269
+ }
270
+ break;
271
+ }
272
+
273
+ case 'd': {
274
+ const note = notes[selectedIndex];
275
+ if (note) {
276
+ deleteConfirmId = note.id;
277
+ draw();
278
+ }
279
+ break;
280
+ }
281
+
282
+ case '/':
283
+ searchMode = true;
284
+ draw();
285
+ break;
286
+
287
+ case 'c':
288
+ setImmediate(async () => {
289
+ await runInteractiveCommand(async () => {
290
+ await openEditor();
291
+ });
292
+ });
293
+ break;
294
+
295
+ case 'enter':
296
+ case 'return': {
297
+ const note = notes[selectedIndex];
298
+ if (note) {
299
+ setImmediate(async () => {
300
+ await runInteractiveCommand(async () => {
301
+ await openEditor(note.title);
302
+ });
303
+ });
304
+ }
305
+ break;
306
+ }
307
+
308
+ case 'q':
309
+ case 'escape':
310
+ exitTUI();
311
+ break;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Entry point to launch the TUI Dashboard.
317
+ */
318
+ export function launchTUI() {
319
+ loadNotes();
320
+
321
+ // Enter raw mode and hide cursor
322
+ process.stdin.setRawMode(true);
323
+ readline.emitKeypressEvents(process.stdin);
324
+ process.stdin.resume();
325
+ process.stdout.write('\x1b[?25l'); // Hide cursor
326
+
327
+ draw();
328
+
329
+ // Listen for terminal resize
330
+ process.stdout.on('resize', () => {
331
+ draw();
332
+ });
333
+
334
+ // Handle keypresses
335
+ process.stdin.on('keypress', handleKeypress);
336
+ }