git0 0.3.35 → 0.3.36

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,990 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ // @ts-expect-error - plain JS module
6
+ import { FileManager } from '../src/fm.js';
7
+
8
+ /**
9
+ * `fm` is a full-screen terminal app, so every terminal touch point is
10
+ * replaced for the duration of a test: stdin raw-mode toggles, the process
11
+ * listeners the constructor registers (which would otherwise fire
12
+ * `process.exit` when the test runner exits), console output, terminal size,
13
+ * and the HOME directory the bookmarks file lives in.
14
+ */
15
+ let home: string;
16
+ let work: string;
17
+ let out: string[];
18
+ let writes: string[];
19
+ let exits: number[];
20
+ let restore: Array<() => void>;
21
+ let fm: any;
22
+
23
+ const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '');
24
+ const text = () => strip(out.join('\n'));
25
+
26
+ function patch<T extends object, K extends keyof T>(obj: T, key: K, value: T[K]) {
27
+ const had = Object.prototype.hasOwnProperty.call(obj, key);
28
+ const orig = (obj as any)[key];
29
+ Object.defineProperty(obj, key, { value, configurable: true, writable: true });
30
+ restore.push(() => {
31
+ if (had) Object.defineProperty(obj, key, { value: orig, configurable: true, writable: true });
32
+ else delete (obj as any)[key];
33
+ });
34
+ }
35
+
36
+ /** Queue answers for `getInput()` prompts. */
37
+ function answers(...a: string[]) {
38
+ const queue = [...a];
39
+ fm.getInput = async () => queue.shift() ?? '';
40
+ fm.waitForKey = async () => {};
41
+ }
42
+
43
+ function makeTree() {
44
+ fs.mkdirSync(path.join(work, 'alpha'));
45
+ fs.mkdirSync(path.join(work, 'zeta'));
46
+ fs.mkdirSync(path.join(work, '.hidden-dir'));
47
+ fs.writeFileSync(path.join(work, 'b.txt'), 'hello\nworld\nthree\nfour');
48
+ fs.writeFileSync(path.join(work, 'a10.md'), '# ten');
49
+ fs.writeFileSync(path.join(work, 'a2.md'), '# two!!');
50
+ fs.writeFileSync(path.join(work, '.secret'), 's');
51
+ fs.writeFileSync(path.join(work, 'run.sh'), '#!/bin/sh');
52
+ fs.chmodSync(path.join(work, 'run.sh'), 0o755);
53
+ fs.writeFileSync(path.join(work, 'pic.png'), 'x'.repeat(2048));
54
+ fs.writeFileSync(path.join(work, 'pic.png'), 'x'.repeat(2048));
55
+ fs.writeFileSync(path.join(work, 'song.mp3'), 'x');
56
+ fs.writeFileSync(path.join(work, 'clip.mp4'), 'x');
57
+ fs.writeFileSync(path.join(work, 'pack.zip'), 'x');
58
+ fs.writeFileSync(path.join(work, 'unknown.xyz'), 'x');
59
+ fs.symlinkSync(path.join(work, 'b.txt'), path.join(work, 'link-to-b'));
60
+ }
61
+
62
+ async function create() {
63
+ const f = new FileManager();
64
+ f.rl.close();
65
+ f.rl = { question: (_q: string, cb: (a: string) => void) => cb('') };
66
+ f.currentPath = work;
67
+ f.history = [work];
68
+ f.historyIndex = 0;
69
+ await f.loadDirectory();
70
+ return f;
71
+ }
72
+
73
+ beforeEach(async () => {
74
+ restore = [];
75
+ out = [];
76
+ writes = [];
77
+ exits = [];
78
+ home = fs.mkdtempSync(path.join(os.tmpdir(), 'fm-home-'));
79
+ work = fs.mkdtempSync(path.join(os.tmpdir(), 'fm-work-'));
80
+ // Bun's os.homedir() ignores runtime HOME changes, so patch the function itself.
81
+ patch(os, 'homedir', () => home);
82
+
83
+ const stdin = process.stdin as any;
84
+ patch(stdin, 'setRawMode', () => stdin);
85
+ patch(stdin, 'resume', () => stdin);
86
+ patch(stdin, 'setEncoding', () => stdin);
87
+
88
+ // The constructor wires cleanup() to SIGINT/SIGTERM/exit; don't let it.
89
+ const realOn = process.on.bind(process);
90
+ patch(process, 'on', ((ev: string, fn: any) =>
91
+ ['SIGINT', 'SIGTERM', 'exit'].includes(ev) ? process : realOn(ev, fn)) as any);
92
+
93
+ patch(process, 'exit', ((code?: number) => void exits.push(code ?? 0)) as any);
94
+ patch(process.stdout, 'write', ((s: any) => (writes.push(String(s)), true)) as any);
95
+ patch(process.stdout, 'columns', 100);
96
+ patch(process.stdout, 'rows', 30);
97
+ patch(console, 'log', (...a: unknown[]) => void out.push(a.join(' ')));
98
+ patch(console, 'clear', () => void out.push('<clear>'));
99
+
100
+ makeTree();
101
+ fm = await create();
102
+ });
103
+
104
+ afterEach(() => {
105
+ process.stdin.removeAllListeners('data');
106
+ for (const r of restore.reverse()) r();
107
+ fs.rmSync(home, { recursive: true, force: true });
108
+ fs.rmSync(work, { recursive: true, force: true });
109
+ });
110
+
111
+ const names = () => fm.items.map((i: any) => i.name);
112
+
113
+ describe('construction and bookmarks', () => {
114
+ test('enables mouse tracking and hides the cursor', () => {
115
+ const joined = writes.join('');
116
+ expect(joined).toContain('\x1b[?1000h');
117
+ expect(joined).toContain('\x1b[?1006h');
118
+ expect(joined).toContain('\x1B[?25l');
119
+ });
120
+
121
+ test('defaults bookmarks to the usual home folders', () => {
122
+ expect(fm.bookmarks.Home).toBe(home);
123
+ expect(fm.bookmarks.Documents).toBe(path.join(home, 'Documents'));
124
+ expect(Object.keys(fm.bookmarks)).toEqual(['Home', 'Documents', 'Downloads', 'Desktop']);
125
+ });
126
+
127
+ test('loads saved bookmarks and ignores a corrupt file', async () => {
128
+ fs.writeFileSync(path.join(home, '.fileman-bookmarks.json'), JSON.stringify({ Proj: '/p' }));
129
+ expect(new FileManager().loadBookmarks()).toEqual({ Proj: '/p' });
130
+ fs.writeFileSync(path.join(home, '.fileman-bookmarks.json'), '{nope');
131
+ expect(Object.keys(new FileManager().loadBookmarks())).toContain('Home');
132
+ });
133
+
134
+ test('saveBookmarks persists and swallows write errors', () => {
135
+ fm.bookmarks = { A: '/a' };
136
+ fm.saveBookmarks();
137
+ expect(JSON.parse(fs.readFileSync(path.join(home, '.fileman-bookmarks.json'), 'utf8'))).toEqual({ A: '/a' });
138
+ patch(os, 'homedir', () => path.join(home, 'missing', 'deeper'));
139
+ expect(() => fm.saveBookmarks()).not.toThrow();
140
+ });
141
+
142
+ test('cleanup restores the terminal, saves bookmarks and exits 0', () => {
143
+ fm.bookmarks = { Saved: '/s' };
144
+ fm.cleanup();
145
+ const joined = writes.join('');
146
+ expect(joined).toContain('\x1B[?25h');
147
+ expect(joined).toContain('\x1b[?1000l');
148
+ expect(exits).toEqual([0]);
149
+ expect(fs.existsSync(path.join(home, '.fileman-bookmarks.json'))).toBe(true);
150
+ });
151
+ });
152
+
153
+ describe('pure helpers', () => {
154
+ test('getFileIcon picks by type, hidden-ness and extension', () => {
155
+ expect(fm.getFileIcon({ isDirectory: true, name: 'src' })).toBe('📁');
156
+ expect(fm.getFileIcon({ isDirectory: true, name: '.git' })).toBe('📂');
157
+ expect(fm.getFileIcon({ isDirectory: false, name: 'a.PNG' })).toBe('🖼️');
158
+ expect(fm.getFileIcon({ isDirectory: false, name: 'a.rs' })).toBe('🦀');
159
+ expect(fm.getFileIcon({ isDirectory: false, name: 'a.unknown' })).toBe('📄');
160
+ });
161
+
162
+ test('getPermissions renders rwx triplets', () => {
163
+ expect(fm.getPermissions(0o755)).toBe('rwxr-xr-x');
164
+ expect(fm.getPermissions(0o644)).toBe('rw-r--r--');
165
+ expect(fm.getPermissions(0o000)).toBe('---------');
166
+ expect(fm.getPermissions(0o100777)).toBe('rwxrwxrwx');
167
+ });
168
+
169
+ test('formatSize scales units', () => {
170
+ expect(fm.formatSize(0)).toBe('0 B');
171
+ expect(fm.formatSize(512)).toBe('512 B');
172
+ expect(fm.formatSize(1536)).toBe('1.5 KB');
173
+ expect(fm.formatSize(5 * 1024 * 1024)).toBe('5 MB');
174
+ expect(fm.formatSize(3 * 1024 ** 3)).toBe('3 GB');
175
+ });
176
+
177
+ test('formatDate includes the date and a time', () => {
178
+ const s = fm.formatDate(new Date(2024, 0, 2, 3, 4));
179
+ expect(s).toContain(new Date(2024, 0, 2).toLocaleDateString());
180
+ expect(s.length).toBeGreaterThan(8);
181
+ });
182
+
183
+ test('truncateName shortens with an ellipsis', () => {
184
+ expect(fm.truncateName('short', 10)).toBe('short');
185
+ expect(fm.truncateName('abcdefghijkl', 8)).toBe('abcde...');
186
+ });
187
+
188
+ test('getFileColor classifies by kind', () => {
189
+ const c = (o: any) => fm.getFileColor({ isSymbolicLink: false, isDirectory: false, mode: 0o644, name: 'x', ...o });
190
+ expect(c({ isSymbolicLink: true })).toBe('\x1b[36m');
191
+ expect(c({ isDirectory: true })).toBe('\x1b[34m');
192
+ expect(c({ name: '.rc' })).toBe('\x1b[2m');
193
+ expect(c({ mode: 0o755 })).toBe('\x1b[32m');
194
+ expect(c({ name: 'a.png' })).toBe('\x1b[35m');
195
+ expect(c({ name: 'a.mp3' })).toBe('\x1b[33m');
196
+ expect(c({ name: 'a.mkv' })).toBe('\x1b[31m');
197
+ expect(c({ name: 'a.zip' })).toBe('\x1b[91m');
198
+ expect(c({ name: 'a.txt' })).toBe('\x1b[37m');
199
+ });
200
+ });
201
+
202
+ describe('loading and sorting', () => {
203
+ test('lists visible entries, directories first, natural name order', () => {
204
+ expect(names()).toEqual([
205
+ 'alpha', 'zeta',
206
+ 'a2.md', 'a10.md', 'b.txt', 'clip.mp4', 'link-to-b', 'pic.png', 'run.sh', 'song.mp3', 'pack.zip', 'unknown.xyz',
207
+ ].sort((a, b) => {
208
+ const dirs = ['alpha', 'zeta'];
209
+ if (dirs.includes(a) !== dirs.includes(b)) return dirs.includes(a) ? -1 : 1;
210
+ return a.localeCompare(b, undefined, { numeric: true });
211
+ }));
212
+ });
213
+
214
+ test('records metadata for each entry', () => {
215
+ const run = fm.items.find((i: any) => i.name === 'run.sh');
216
+ expect(run.isFile).toBe(true);
217
+ expect(run.permissions).toBe('rwxr-xr-x');
218
+ expect(run.type).toBe('.sh');
219
+ const dir = fm.items.find((i: any) => i.name === 'alpha');
220
+ expect(dir.isDirectory).toBe(true);
221
+ expect(dir.type).toBe('directory');
222
+ expect(fm.items.find((i: any) => i.name === 'link-to-b').isSymbolicLink).toBe(true);
223
+ });
224
+
225
+ test('showHidden reveals dotfiles', async () => {
226
+ expect(names()).not.toContain('.secret');
227
+ fm.showHidden = true;
228
+ await fm.loadDirectory();
229
+ expect(names()).toEqual(expect.arrayContaining(['.secret', '.hidden-dir']));
230
+ });
231
+
232
+ test('filterText narrows the list case-insensitively', async () => {
233
+ fm.filterText = 'PIC';
234
+ await fm.loadDirectory();
235
+ expect(names()).toEqual(['pic.png']);
236
+ });
237
+
238
+ test('sorts by size, date, type and descending', async () => {
239
+ fm.sortBy = 'size';
240
+ await fm.loadDirectory();
241
+ const files = fm.items.filter((i: any) => !i.isDirectory).map((i: any) => i.size);
242
+ expect(files).toEqual([...files].sort((a, b) => a - b));
243
+
244
+ fm.sortBy = 'type';
245
+ await fm.loadDirectory();
246
+ const types = fm.items.filter((i: any) => !i.isDirectory).map((i: any) => i.type);
247
+ expect(types).toEqual([...types].sort((a, b) => a.localeCompare(b)));
248
+
249
+ const old = new Date(2001, 1, 1);
250
+ fs.utimesSync(path.join(work, 'b.txt'), old, old);
251
+ fm.sortBy = 'date';
252
+ await fm.loadDirectory();
253
+ const firstFile = fm.items.find((i: any) => !i.isDirectory);
254
+ expect(firstFile.name).toBe('b.txt');
255
+
256
+ fm.sortBy = 'name';
257
+ fm.sortOrder = 'desc';
258
+ await fm.loadDirectory();
259
+ expect(fm.items.find((i: any) => !i.isDirectory).name).toBe('unknown.xyz');
260
+ expect(fm.items[0].isDirectory).toBe(true);
261
+ });
262
+
263
+ test('clamps the selection when the list shrinks', async () => {
264
+ fm.selectedIndex = 999;
265
+ await fm.loadDirectory();
266
+ expect(fm.selectedIndex).toBe(fm.items.length - 1);
267
+ });
268
+
269
+ test('loading another directory returns its items without touching the view', async () => {
270
+ const before = fm.items;
271
+ const other = await fm.loadDirectory(path.join(work, 'alpha'));
272
+ expect(other).toEqual([]);
273
+ expect(fm.items).toBe(before);
274
+ });
275
+
276
+ test('an unreadable directory yields an empty list', async () => {
277
+ fm.currentPath = path.join(work, 'does-not-exist');
278
+ expect(await fm.loadDirectory()).toEqual([]);
279
+ expect(fm.items).toEqual([]);
280
+ expect(fm.selectedIndex).toBe(0);
281
+ expect(await fm.loadDirectory(path.join(work, 'nope'))).toEqual([]);
282
+ });
283
+
284
+ test('a broken symlink falls back to zeroed stats', async () => {
285
+ fs.symlinkSync(path.join(work, 'gone'), path.join(work, 'dangling'));
286
+ await fm.loadDirectory();
287
+ const d = fm.items.find((i: any) => i.name === 'dangling');
288
+ expect(d.size).toBe(0);
289
+ expect(d.permissions).toBe('---------');
290
+ });
291
+ });
292
+
293
+ describe('selection and modes', () => {
294
+ test('moveSelection stays in range', () => {
295
+ fm.moveSelection(-1);
296
+ expect(fm.selectedIndex).toBe(0);
297
+ fm.moveSelection(1);
298
+ expect(fm.selectedIndex).toBe(1);
299
+ fm.selectedIndex = fm.items.length - 1;
300
+ fm.moveSelection(1);
301
+ expect(fm.selectedIndex).toBe(fm.items.length - 1);
302
+ });
303
+
304
+ test('toggleMultiSelect adds/removes and tracks the mode flag', () => {
305
+ fm.toggleMultiSelect();
306
+ expect(fm.selectedItems.has(0)).toBe(true);
307
+ expect(fm.multiSelectMode).toBe(true);
308
+ fm.toggleMultiSelect();
309
+ expect(fm.selectedItems.size).toBe(0);
310
+ expect(fm.multiSelectMode).toBe(false);
311
+ });
312
+
313
+ test('selectAll selects every row', () => {
314
+ fm.selectAll();
315
+ expect(fm.selectedItems.size).toBe(fm.items.length);
316
+ expect(fm.multiSelectMode).toBe(true);
317
+ });
318
+
319
+ test('cycleSortMode and cycleViewMode wrap around', () => {
320
+ const sorts = [];
321
+ for (let i = 0; i < 5; i++) {
322
+ fm.cycleSortMode();
323
+ sorts.push(fm.sortBy);
324
+ }
325
+ expect(sorts).toEqual(['size', 'date', 'type', 'name', 'size']);
326
+ const views = [];
327
+ for (let i = 0; i < 4; i++) {
328
+ fm.cycleViewMode();
329
+ views.push(fm.viewMode);
330
+ }
331
+ expect(views).toEqual(['simple', 'grid', 'detailed', 'simple']);
332
+ });
333
+ });
334
+
335
+ describe('navigation', () => {
336
+ test('openSelected enters a directory and records history', async () => {
337
+ fm.selectedIndex = names().indexOf('alpha');
338
+ await fm.openSelected();
339
+ expect(fm.currentPath).toBe(path.join(work, 'alpha'));
340
+ expect(fm.history).toEqual([work, path.join(work, 'alpha')]);
341
+ expect(fm.selectedIndex).toBe(0);
342
+ });
343
+
344
+ test('openSelected on an empty list is a no-op', async () => {
345
+ fm.items = [];
346
+ await fm.openSelected();
347
+ expect(fm.currentPath).toBe(work);
348
+ });
349
+
350
+ test('openSelected reports a file that cannot be opened', async () => {
351
+ answers();
352
+ fm.items = [{ name: 'ghost.txt', isDirectory: false, fullPath: '/definitely/not/here/ghost.txt' }];
353
+ fm.selectedIndex = 0;
354
+ await fm.openSelected();
355
+ expect(text()).toContain('Cannot open file: ghost.txt');
356
+ });
357
+
358
+ test('goUp moves to the parent and stops at the root', async () => {
359
+ fm.currentPath = path.join(work, 'alpha');
360
+ await fm.goUp();
361
+ expect(fm.currentPath).toBe(work);
362
+ fm.currentPath = path.parse(work).root;
363
+ const hist = fm.history.length;
364
+ await fm.goUp();
365
+ expect(fm.currentPath).toBe(path.parse(work).root);
366
+ expect(fm.history.length).toBe(hist);
367
+ });
368
+
369
+ test('back and forward walk the history', async () => {
370
+ fm.selectedIndex = names().indexOf('alpha');
371
+ await fm.openSelected();
372
+ fm.goBack();
373
+ expect(fm.currentPath).toBe(work);
374
+ fm.goBack();
375
+ expect(fm.historyIndex).toBe(0);
376
+ fm.goForward();
377
+ expect(fm.historyIndex).toBe(1);
378
+ expect(fm.currentPath).toBe(path.join(work, 'alpha'));
379
+ fm.goForward();
380
+ fm.goForward();
381
+ expect(fm.historyIndex).toBe(1);
382
+ });
383
+
384
+ test('addToHistory drops the forward branch and caps the length at 50', () => {
385
+ fm.history = ['a', 'b', 'c'];
386
+ fm.historyIndex = 0;
387
+ fm.addToHistory('x');
388
+ expect(fm.history).toEqual(['a', 'x']);
389
+
390
+ fm.history = Array.from({ length: 50 }, (_, i) => `p${i}`);
391
+ fm.historyIndex = 49;
392
+ fm.addToHistory('new');
393
+ expect(fm.history).toHaveLength(50);
394
+ expect(fm.history[0]).toBe('p1');
395
+ expect(fm.history.at(-1)).toBe('new');
396
+ expect(fm.historyIndex).toBe(49);
397
+ });
398
+ });
399
+
400
+ describe('clipboard operations', () => {
401
+ const idx = (n: string) => names().indexOf(n);
402
+
403
+ test('copy and cut capture the highlighted or multi-selected paths', () => {
404
+ fm.selectedIndex = idx('b.txt');
405
+ fm.copySelected();
406
+ expect(fm.clipboard).toEqual([path.join(work, 'b.txt')]);
407
+ expect(fm.clipboardAction).toBe('copy');
408
+
409
+ fm.selectedItems = new Set([idx('a2.md'), idx('a10.md')]);
410
+ fm.cutSelected();
411
+ expect(fm.clipboard).toHaveLength(2);
412
+ expect(fm.clipboardAction).toBe('cut');
413
+ });
414
+
415
+ test('copy/cut with nothing to take leave the clipboard empty', () => {
416
+ fm.items = [];
417
+ fm.copySelected();
418
+ fm.cutSelected();
419
+ expect(fm.clipboard).toEqual([]);
420
+ expect(fm.clipboardAction).toBe('cut');
421
+ });
422
+
423
+ test('paste copies files and whole directories', async () => {
424
+ fs.writeFileSync(path.join(work, 'alpha', 'inner.txt'), 'inner');
425
+ fs.mkdirSync(path.join(work, 'alpha', 'deep'));
426
+ fs.writeFileSync(path.join(work, 'alpha', 'deep', 'd.txt'), 'deep');
427
+ fm.clipboard = [path.join(work, 'alpha'), path.join(work, 'b.txt')];
428
+ fm.clipboardAction = 'copy';
429
+ fm.currentPath = path.join(work, 'zeta');
430
+ await fm.paste();
431
+ expect(fs.readFileSync(path.join(work, 'zeta', 'alpha', 'deep', 'd.txt'), 'utf8')).toBe('deep');
432
+ expect(fs.readFileSync(path.join(work, 'zeta', 'b.txt'), 'utf8')).toContain('hello');
433
+ expect(fs.existsSync(path.join(work, 'b.txt'))).toBe(true);
434
+ expect(fm.clipboard).toHaveLength(2);
435
+ });
436
+
437
+ test('paste after cut moves the file and clears the clipboard', async () => {
438
+ fm.clipboard = [path.join(work, 'b.txt')];
439
+ fm.clipboardAction = 'cut';
440
+ fm.currentPath = path.join(work, 'zeta');
441
+ await fm.paste();
442
+ expect(fs.existsSync(path.join(work, 'b.txt'))).toBe(false);
443
+ expect(fs.existsSync(path.join(work, 'zeta', 'b.txt'))).toBe(true);
444
+ expect(fm.clipboard).toEqual([]);
445
+ expect(fm.clipboardAction).toBeNull();
446
+ });
447
+
448
+ test('paste with an empty clipboard does nothing, and reports failures', async () => {
449
+ await fm.paste();
450
+ expect(out).toEqual([]);
451
+ answers();
452
+ fm.clipboard = [path.join(work, 'missing.txt')];
453
+ fm.clipboardAction = 'copy';
454
+ await fm.paste();
455
+ expect(text()).toContain('Paste failed');
456
+ });
457
+ });
458
+
459
+ describe('delete, rename, create', () => {
460
+ const idx = (n: string) => names().indexOf(n);
461
+
462
+ test('delete asks for confirmation and removes files and directories', async () => {
463
+ fs.writeFileSync(path.join(work, 'zeta', 'x'), 'x');
464
+ fm.selectedItems = new Set([idx('zeta'), idx('b.txt')]);
465
+ answers('y');
466
+ await fm.deleteSelected();
467
+ expect(text()).toContain('Delete 2 item(s): zeta, b.txt? (y/N)');
468
+ expect(fs.existsSync(path.join(work, 'zeta'))).toBe(false);
469
+ expect(fs.existsSync(path.join(work, 'b.txt'))).toBe(false);
470
+ expect(fm.selectedItems.size).toBe(0);
471
+ });
472
+
473
+ test('delete does nothing unless confirmed', async () => {
474
+ fm.selectedIndex = idx('b.txt');
475
+ answers('n');
476
+ await fm.deleteSelected();
477
+ expect(fs.existsSync(path.join(work, 'b.txt'))).toBe(true);
478
+ });
479
+
480
+ test('delete with an empty list is a no-op', async () => {
481
+ fm.items = [];
482
+ answers('y');
483
+ await fm.deleteSelected();
484
+ expect(out).toEqual([]);
485
+ });
486
+
487
+ test('delete reports failures', async () => {
488
+ fm.items = [{ name: 'ghost', isDirectory: false, fullPath: path.join(work, 'ghost') }];
489
+ fm.selectedIndex = 0;
490
+ answers('Y');
491
+ await fm.deleteSelected();
492
+ expect(text()).toContain('Delete failed');
493
+ });
494
+
495
+ test('rename moves the file, ignoring blank or unchanged names', async () => {
496
+ fm.selectedIndex = idx('b.txt');
497
+ answers('renamed.txt');
498
+ await fm.renameSelected();
499
+ expect(fs.existsSync(path.join(work, 'renamed.txt'))).toBe(true);
500
+
501
+ fm.selectedIndex = idx('renamed.txt');
502
+ answers(' ');
503
+ await fm.renameSelected();
504
+ answers('renamed.txt');
505
+ await fm.renameSelected();
506
+ expect(fs.existsSync(path.join(work, 'renamed.txt'))).toBe(true);
507
+ });
508
+
509
+ test('rename on an empty list is a no-op and failures are reported', async () => {
510
+ fm.items = [];
511
+ await fm.renameSelected();
512
+ expect(out).toEqual([]);
513
+ fm.items = [{ name: 'ghost', isDirectory: false, fullPath: path.join(work, 'ghost') }];
514
+ fm.selectedIndex = 0;
515
+ answers('new-name');
516
+ await fm.renameSelected();
517
+ expect(text()).toContain('Rename failed');
518
+ });
519
+
520
+ test('createNew makes a file or directory (asking when no type is given)', async () => {
521
+ answers('made.txt');
522
+ await fm.createNew('file');
523
+ expect(fs.statSync(path.join(work, 'made.txt')).isFile()).toBe(true);
524
+
525
+ answers('madedir');
526
+ await fm.createNew('directory');
527
+ expect(fs.statSync(path.join(work, 'madedir')).isDirectory()).toBe(true);
528
+
529
+ answers('d', 'asked-dir');
530
+ await fm.createNew();
531
+ expect(fs.statSync(path.join(work, 'asked-dir')).isDirectory()).toBe(true);
532
+
533
+ answers('f', 'asked-file');
534
+ await fm.createNew();
535
+ expect(fs.statSync(path.join(work, 'asked-file')).isFile()).toBe(true);
536
+ });
537
+
538
+ test('createNew ignores a blank name and reports failures', async () => {
539
+ answers(' ');
540
+ await fm.createNew('file');
541
+ answers('alpha');
542
+ await fm.createNew('directory');
543
+ expect(text()).toContain('Create failed');
544
+ });
545
+ });
546
+
547
+ describe('bookmarks and settings screens', () => {
548
+ test('showBookmarks navigates to the chosen bookmark', async () => {
549
+ fm.bookmarks = { Alpha: path.join(work, 'alpha') };
550
+ answers('1');
551
+ await fm.showBookmarks();
552
+ expect(text()).toContain(`1. Alpha -> ${path.join(work, 'alpha')}`);
553
+ expect(fm.currentPath).toBe(path.join(work, 'alpha'));
554
+ });
555
+
556
+ test('showBookmarks warns about missing paths and ignores bad input', async () => {
557
+ fm.bookmarks = { Gone: path.join(work, 'gone') };
558
+ answers('1');
559
+ await fm.showBookmarks();
560
+ expect(text()).toContain('Bookmark path does not exist');
561
+ expect(fm.currentPath).toBe(work);
562
+ answers('');
563
+ await fm.showBookmarks();
564
+ answers('9');
565
+ await fm.showBookmarks();
566
+ expect(fm.currentPath).toBe(work);
567
+ });
568
+
569
+ test('addBookmark stores and persists the current directory', async () => {
570
+ answers(' Work ');
571
+ await fm.addBookmark();
572
+ expect(fm.bookmarks.Work).toBe(work);
573
+ expect(JSON.parse(fs.readFileSync(path.join(home, '.fileman-bookmarks.json'), 'utf8')).Work).toBe(work);
574
+ answers(' ');
575
+ await fm.addBookmark();
576
+ expect(Object.keys(fm.bookmarks)).not.toContain('');
577
+ });
578
+
579
+ test.each([
580
+ ['1', () => expect(fm.showHidden).toBe(true)],
581
+ ['2', () => expect(fm.sortBy).toBe('size')],
582
+ ['3', () => expect(fm.sortOrder).toBe('desc')],
583
+ ['4', () => expect(fm.viewMode).toBe('simple')],
584
+ ['5', () => expect(fm.previewMode).toBe(true)],
585
+ ['6', () => expect(fm.splitView).toBe(true)],
586
+ ['', () => expect(fm.showHidden).toBe(false)],
587
+ ])('settings choice %j', async (choice, check) => {
588
+ answers(choice as string);
589
+ await fm.showSettings();
590
+ expect(text()).toContain('=== Settings ===');
591
+ (check as () => void)();
592
+ });
593
+
594
+ test('showHelp lists the key bindings', async () => {
595
+ answers();
596
+ await fm.showHelp();
597
+ expect(text()).toContain('File Manager Help');
598
+ expect(text()).toContain('Press any key to continue');
599
+ });
600
+ });
601
+
602
+ describe('rendering', () => {
603
+ test('render draws header, toolbar, list, status bar and clipboard line', () => {
604
+ fm.clipboard = [path.join(work, 'b.txt')];
605
+ fm.clipboardAction = 'copy';
606
+ fm.render();
607
+ const t = text();
608
+ expect(t).toContain('File Manager');
609
+ expect(t).toContain(work);
610
+ expect(t).toContain('alpha');
611
+ expect(t).toContain(`${fm.items.length} items | Sort: name asc | View: detailed`);
612
+ expect(t).toContain('Copied: b.txt');
613
+ fm.clipboard = [path.join(work, 'a'), path.join(work, 'b')];
614
+ fm.clipboardAction = 'cut';
615
+ out.length = 0;
616
+ fm.render();
617
+ expect(text()).toContain('Cut: 2 items');
618
+ });
619
+
620
+ test('render truncates a very long path and shows the search bar', () => {
621
+ fm.currentPath = '/' + 'x'.repeat(200);
622
+ fm.searchMode = true;
623
+ fm.filterText = 'abc';
624
+ fm.render();
625
+ expect(text()).toContain('...');
626
+ expect(text()).toContain('Search: abc_');
627
+ });
628
+
629
+ test('renderFileList handles an empty directory and the scroll indicator', () => {
630
+ fm.items = [];
631
+ fm.renderFileList();
632
+ expect(text()).toContain('Empty directory');
633
+
634
+ out.length = 0;
635
+ patch(process.stdout, 'rows', 10);
636
+ fm.items = Array.from({ length: 40 }, (_, i) => ({
637
+ name: `f${i}`, isDirectory: false, size: 1, mtime: new Date(), permissions: 'rw-r--r--', mode: 0o644, fullPath: `/f${i}`,
638
+ }));
639
+ fm.selectedIndex = 20;
640
+ fm.renderFileList();
641
+ expect(text()).toMatch(/\[\d+%\] 21\/40/);
642
+ });
643
+
644
+ test('renderFileItem supports detailed, simple and grid modes with selection markers', () => {
645
+ const item = fm.items.find((i: any) => i.name === 'b.txt');
646
+ const idx = fm.items.indexOf(item);
647
+
648
+ fm.selectedIndex = idx;
649
+ fm.selectedItems = new Set([idx]);
650
+ fm.renderFileItem(idx, 100);
651
+ expect(out.at(-1)).toContain('\x1b[43m');
652
+
653
+ fm.selectedItems = new Set();
654
+ fm.renderFileItem(idx, 100);
655
+ expect(out.at(-1)).toContain('\x1b[47m');
656
+
657
+ fm.selectedIndex = 0;
658
+ fm.selectedItems = new Set([idx]);
659
+ fm.renderFileItem(idx, 100);
660
+ expect(out.at(-1)).toContain('\x1b[46m');
661
+
662
+ fm.selectedItems = new Set();
663
+ fm.renderFileItem(idx, 100);
664
+ expect(out.at(-1)).toContain('b.txt');
665
+ expect(out.at(-1)).toContain('rw');
666
+
667
+ const dir = fm.items.findIndex((i: any) => i.isDirectory);
668
+ fm.renderFileItem(dir, 100);
669
+ expect(out.at(-1)).toContain('<DIR>');
670
+
671
+ fm.viewMode = 'simple';
672
+ fm.renderFileItem(idx, 100);
673
+ expect(strip(out.at(-1)!)).toContain('b.txt');
674
+
675
+ fm.viewMode = 'grid';
676
+ writes.length = 0;
677
+ out.length = 0;
678
+ fm.selectedIndex = 0;
679
+ for (let i = 0; i < 4; i++) fm.renderFileItem(i, 100);
680
+ expect(writes.join('')).toContain('alpha');
681
+ expect(out).toContain('');
682
+ });
683
+
684
+ test('split view prints both pane headers', () => {
685
+ fm.splitView = true;
686
+ fm.renderFileList();
687
+ expect(text()).toContain(`Left Pane: ${work}`);
688
+ expect(text()).toContain(`Right Pane: ${fm.rightPanePath}`);
689
+ });
690
+
691
+ test('status bar mentions selection count and handles an empty list', () => {
692
+ fm.selectedItems = new Set([1, 2]);
693
+ fm.renderStatusBar();
694
+ expect(text()).toContain('Selected: 2');
695
+ out.length = 0;
696
+ fm.items = [];
697
+ fm.selectedItems = new Set();
698
+ fm.renderStatusBar();
699
+ expect(text()).toContain('0 items');
700
+ });
701
+
702
+ test('status bar trims overlong lines', () => {
703
+ patch(process.stdout, 'columns', 20);
704
+ fm.renderStatusBar();
705
+ expect(out.at(-1)!.replace('\x1b[0m', '').length).toBeLessThanOrEqual(30);
706
+ });
707
+
708
+ test('toolbar prints every icon', () => {
709
+ fm.renderToolbar();
710
+ expect(out.at(-1)).toContain('🏠');
711
+ expect(out.at(-1)).toContain('🔧');
712
+ });
713
+
714
+ test('preview shows file info and a content excerpt', () => {
715
+ fm.previewMode = true;
716
+ fm.selectedIndex = names().indexOf('b.txt');
717
+ fm.renderPreview();
718
+ const t = text();
719
+ expect(t).toContain('--- Preview ---');
720
+ expect(t).toContain('Content preview:');
721
+ expect(t).toContain('hello');
722
+ expect(t).not.toContain('four');
723
+ });
724
+
725
+ test('preview truncates long lines, skips binary types and survives read errors', () => {
726
+ fs.writeFileSync(path.join(work, 'long.txt'), 'y'.repeat(80));
727
+ fm.selectedIndex = names().indexOf('pic.png');
728
+ fm.renderPreview();
729
+ expect(text()).not.toContain('Content preview');
730
+
731
+ out.length = 0;
732
+ fm.items = [{ name: 'long.txt', isDirectory: false, size: 80, mtime: new Date(), permissions: 'rw-', fullPath: path.join(work, 'long.txt') }];
733
+ fm.selectedIndex = 0;
734
+ fm.renderPreview();
735
+ expect(text()).toContain('y'.repeat(50) + '...');
736
+
737
+ out.length = 0;
738
+ fm.items = [{ name: 'gone.txt', isDirectory: false, size: 1, mtime: new Date(), permissions: 'rw-', fullPath: path.join(work, 'gone.txt') }];
739
+ fm.renderPreview();
740
+ expect(text()).toContain('Cannot preview file content');
741
+ });
742
+
743
+ test('preview summarises a directory', async () => {
744
+ for (let i = 0; i < 7; i++) fs.writeFileSync(path.join(work, 'alpha', `f${i}.txt`), '');
745
+ fm.selectedIndex = names().indexOf('alpha');
746
+ fm.renderPreview();
747
+ await new Promise((r) => setTimeout(r, 30));
748
+ expect(text()).toContain('Directory contains 7 items');
749
+ expect(text()).toContain('... and 2 more');
750
+ });
751
+
752
+ test('preview with no selection prints nothing', () => {
753
+ fm.items = [];
754
+ fm.renderPreview();
755
+ expect(out).toEqual([]);
756
+ });
757
+
758
+ test('render includes the preview pane when enabled', () => {
759
+ fm.previewMode = true;
760
+ fm.render();
761
+ expect(text()).toContain('--- Preview ---');
762
+ });
763
+ });
764
+
765
+ describe('key handling', () => {
766
+ const press = async (...keys: string[]) => {
767
+ for (const k of keys) await fm.handleKeyPress(k);
768
+ };
769
+
770
+ test('arrow and vim keys move the selection', async () => {
771
+ await press('\u001b[B', 'j');
772
+ expect(fm.selectedIndex).toBe(2);
773
+ await press('\u001b[A', 'k');
774
+ expect(fm.selectedIndex).toBe(0);
775
+ await press('G');
776
+ expect(fm.selectedIndex).toBe(fm.items.length - 1);
777
+ await press('g');
778
+ expect(fm.selectedIndex).toBe(0);
779
+ });
780
+
781
+ test('enter/right open a directory; right ignores files; left goes up', async () => {
782
+ await press('\r');
783
+ expect(fm.currentPath).toBe(path.join(work, 'alpha'));
784
+ await press('h');
785
+ expect(fm.currentPath).toBe(work);
786
+ await press('\u001b[C');
787
+ expect(fm.currentPath).toBe(path.join(work, 'alpha'));
788
+ await press('\u001b[D');
789
+ fm.selectedIndex = names().indexOf('b.txt');
790
+ await press('l');
791
+ expect(fm.currentPath).toBe(work);
792
+ fm.items = [];
793
+ await press('l');
794
+ await press('\n');
795
+ });
796
+
797
+ test('space/a/A drive multi-select', async () => {
798
+ await press(' ');
799
+ expect(fm.selectedItems.size).toBe(1);
800
+ await press('a');
801
+ expect(fm.selectedItems.size).toBe(fm.items.length);
802
+ await press('A');
803
+ expect(fm.selectedItems.size).toBe(0);
804
+ });
805
+
806
+ test('s, S, v, H, P, T toggle view settings', async () => {
807
+ await press('s');
808
+ expect(fm.sortBy).toBe('size');
809
+ await press('S');
810
+ expect(fm.sortOrder).toBe('desc');
811
+ await press('v');
812
+ expect(fm.viewMode).toBe('simple');
813
+ await press('H');
814
+ expect(fm.showHidden).toBe(true);
815
+ await press('P');
816
+ expect(fm.previewMode).toBe(true);
817
+ await press('T');
818
+ expect(fm.splitView).toBe(true);
819
+ await press('R');
820
+ });
821
+
822
+ test('c/x/p copy, cut and paste via keys', async () => {
823
+ fm.selectedIndex = names().indexOf('b.txt');
824
+ await press('c');
825
+ expect(fm.clipboardAction).toBe('copy');
826
+ await press('x');
827
+ expect(fm.clipboardAction).toBe('cut');
828
+ fm.currentPath = path.join(work, 'zeta');
829
+ await press('p');
830
+ expect(fs.existsSync(path.join(work, 'zeta', 'b.txt'))).toBe(true);
831
+ });
832
+
833
+ test('d, n, N, r prompt for input', async () => {
834
+ fm.selectedIndex = names().indexOf('b.txt');
835
+ answers('y');
836
+ await press('d');
837
+ expect(fs.existsSync(path.join(work, 'b.txt'))).toBe(false);
838
+ answers('f', 'k-new');
839
+ await press('n');
840
+ answers('k-dir');
841
+ await press('N');
842
+ expect(fs.existsSync(path.join(work, 'k-new'))).toBe(true);
843
+ expect(fs.statSync(path.join(work, 'k-dir')).isDirectory()).toBe(true);
844
+ fm.selectedIndex = names().indexOf('k-new');
845
+ answers('k-renamed');
846
+ await press('r');
847
+ expect(fs.existsSync(path.join(work, 'k-renamed'))).toBe(true);
848
+ });
849
+
850
+ test('b, B, ? open the bookmark, add-bookmark and help screens', async () => {
851
+ answers('');
852
+ await press('b');
853
+ answers('Mark');
854
+ await press('B');
855
+ answers();
856
+ await press('?');
857
+ expect(fm.bookmarks.Mark).toBe(work);
858
+ expect(text()).toContain('File Manager Help');
859
+ });
860
+
861
+ test('u and U step through history', async () => {
862
+ await press('\r');
863
+ await press('u');
864
+ expect(fm.currentPath).toBe(work);
865
+ await press('U');
866
+ expect(fm.currentPath).toBe(path.join(work, 'alpha'));
867
+ });
868
+
869
+ test('q and ctrl-c quit', async () => {
870
+ await press('q');
871
+ await press('\u0003');
872
+ expect(exits).toEqual([0, 0]);
873
+ });
874
+
875
+ test('f and / enter search mode, where typing filters live', async () => {
876
+ await press('f');
877
+ expect(fm.searchMode).toBe(true);
878
+ await press('p', 'i', 'c');
879
+ expect(fm.filterText).toBe('pic');
880
+ expect(names()).toEqual(['pic.png']);
881
+ await press('\x7f');
882
+ expect(fm.filterText).toBe('pi');
883
+ await press('\b');
884
+ expect(fm.filterText).toBe('p');
885
+ await press('\r');
886
+ expect(fm.searchMode).toBe(false);
887
+ await press('/');
888
+ expect(fm.filterText).toBe('');
889
+ await press('x', '\x1b');
890
+ expect(fm.searchMode).toBe(false);
891
+ });
892
+
893
+ test('unrecognised keys and control sequences in search are ignored', async () => {
894
+ await press('f');
895
+ await press('\u001b[A');
896
+ expect(fm.filterText).toBe('');
897
+ await press('\x1b');
898
+ await press('~');
899
+ });
900
+
901
+ test('mouse sequences are routed to the toolbar', async () => {
902
+ fm.executeToolbarAction = async (a: string) => void (fm.lastAction = a);
903
+ await press('\x1b[<0;4;1M');
904
+ expect(fm.lastAction).toBe(fm.toolbarIcons[Math.floor(4 / 3)].action);
905
+ fm.lastAction = undefined;
906
+ await press('\x1b[M!!!');
907
+ expect(fm.lastAction).toBeUndefined();
908
+ await press('\x1b[<1;1;1M');
909
+ await press('\x1b[<0;999;1M');
910
+ expect(fm.lastAction).toBeUndefined();
911
+ });
912
+ });
913
+
914
+ describe('toolbar actions', () => {
915
+ test('navigation actions', async () => {
916
+ await fm.executeToolbarAction('home');
917
+ expect(fm.currentPath).toBe(home);
918
+ await fm.executeToolbarAction('up');
919
+ await fm.executeToolbarAction('back');
920
+ await fm.executeToolbarAction('forward');
921
+ await fm.executeToolbarAction('refresh');
922
+ });
923
+
924
+ test('file actions', async () => {
925
+ fm.currentPath = work;
926
+ await fm.loadDirectory();
927
+ fm.selectedIndex = names().indexOf('b.txt');
928
+ await fm.executeToolbarAction('copy');
929
+ expect(fm.clipboardAction).toBe('copy');
930
+ await fm.executeToolbarAction('cut');
931
+ expect(fm.clipboardAction).toBe('cut');
932
+ fm.currentPath = path.join(work, 'zeta');
933
+ await fm.executeToolbarAction('paste');
934
+ expect(fs.existsSync(path.join(work, 'zeta', 'b.txt'))).toBe(true);
935
+
936
+ fm.currentPath = work;
937
+ await fm.loadDirectory();
938
+ answers('tb-dir');
939
+ await fm.executeToolbarAction('newdir');
940
+ answers('tb-file');
941
+ await fm.executeToolbarAction('newfile');
942
+ expect(fs.existsSync(path.join(work, 'tb-dir'))).toBe(true);
943
+ expect(fs.existsSync(path.join(work, 'tb-file'))).toBe(true);
944
+ fm.selectedIndex = names().indexOf('tb-file');
945
+ answers('y');
946
+ await fm.executeToolbarAction('delete');
947
+ expect(fs.existsSync(path.join(work, 'tb-file'))).toBe(false);
948
+ });
949
+
950
+ test('view actions', async () => {
951
+ await fm.executeToolbarAction('search');
952
+ expect(fm.searchMode).toBe(true);
953
+ await fm.executeToolbarAction('preview');
954
+ expect(fm.previewMode).toBe(true);
955
+ await fm.executeToolbarAction('view');
956
+ expect(fm.viewMode).toBe('simple');
957
+ answers('');
958
+ await fm.executeToolbarAction('bookmark');
959
+ answers('');
960
+ await fm.executeToolbarAction('settings');
961
+ await fm.executeToolbarAction('unknown-action');
962
+ });
963
+ });
964
+
965
+ describe('input helpers and run loop', () => {
966
+ test('getInput resolves with the readline answer and toggles the cursor', async () => {
967
+ fm.rl = { question: (_q: string, cb: (a: string) => void) => cb('typed') };
968
+ expect(await fm.getInput()).toBe('typed');
969
+ const joined = writes.join('');
970
+ expect(joined).toContain('\x1B[?25h');
971
+ expect(joined).toContain('\x1B[?25l');
972
+ });
973
+
974
+ test('waitForKey resolves on the next stdin data event', async () => {
975
+ const p = fm.waitForKey();
976
+ process.stdin.emit('data', 'x');
977
+ await p;
978
+ });
979
+
980
+ test('run loads the directory, renders, and re-renders after each key', async () => {
981
+ await fm.run();
982
+ const rendered = text();
983
+ expect(rendered).toContain('File Manager');
984
+ out.length = 0;
985
+ process.stdin.emit('data', 'j');
986
+ await new Promise((r) => setTimeout(r, 30));
987
+ expect(fm.selectedIndex).toBe(1);
988
+ expect(text()).toContain('File Manager');
989
+ });
990
+ });