@suds-cli/filetree 0.1.0-alpha.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 ADDED
@@ -0,0 +1,36 @@
1
+ # @suds-cli/filetree
2
+
3
+ Navigable file tree browser component for Suds terminal UIs.
4
+
5
+ ## Features
6
+
7
+ - Keyboard navigation (up/down)
8
+ - Scrollable viewport
9
+ - File metadata display (date, permissions, size)
10
+ - Hidden file support
11
+ - Customizable key bindings and styles
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pnpm add @suds-cli/filetree
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```typescript
22
+ import { FiletreeModel } from '@suds-cli/filetree'
23
+ import { Program } from '@suds-cli/tea'
24
+
25
+ const filetree = FiletreeModel.new({
26
+ currentDir: process.cwd(),
27
+ showHidden: false,
28
+ })
29
+
30
+ const program = new Program(filetree)
31
+ await program.run()
32
+ ```
33
+
34
+ ## API
35
+
36
+ See the [API documentation](../../docs/api/filetree.md) for detailed information.
package/dist/index.cjs ADDED
@@ -0,0 +1,470 @@
1
+ 'use strict';
2
+
3
+ var key = require('@suds-cli/key');
4
+ var tea = require('@suds-cli/tea');
5
+ var chapstick = require('@suds-cli/chapstick');
6
+
7
+ // src/model.ts
8
+ var defaultKeyMap = {
9
+ down: key.newBinding({ keys: ["j", "down", "ctrl+n"] }).withHelp("j/\u2193", "down"),
10
+ up: key.newBinding({ keys: ["k", "up", "ctrl+p"] }).withHelp("k/\u2191", "up")
11
+ };
12
+ function defaultStyles() {
13
+ return {
14
+ selectedItem: new chapstick.Style().foreground("#00ff00").bold(true),
15
+ normalItem: new chapstick.Style()
16
+ };
17
+ }
18
+ function mergeStyles(overrides) {
19
+ if (!overrides) {
20
+ return defaultStyles();
21
+ }
22
+ return { ...defaultStyles(), ...overrides };
23
+ }
24
+
25
+ // src/messages.ts
26
+ var GetDirectoryListingMsg = class {
27
+ constructor(items) {
28
+ this.items = items;
29
+ }
30
+ _tag = "filetree-get-directory-listing";
31
+ };
32
+ var ErrorMsg = class {
33
+ constructor(error) {
34
+ this.error = error;
35
+ }
36
+ _tag = "filetree-error";
37
+ };
38
+
39
+ // src/fs.ts
40
+ function convertBytesToSizeString(size) {
41
+ if (size < 1024) {
42
+ return `${size}B`;
43
+ }
44
+ const units = ["K", "M", "G", "T"];
45
+ let unitIndex = -1;
46
+ let adjustedSize = size;
47
+ while (adjustedSize >= 1024 && unitIndex < units.length - 1) {
48
+ adjustedSize /= 1024;
49
+ unitIndex++;
50
+ }
51
+ const unit = units[unitIndex];
52
+ if (!unit) {
53
+ return `${size}B`;
54
+ }
55
+ return `${adjustedSize.toFixed(1)}${unit}`;
56
+ }
57
+ function formatPermissions(mode, isDirectory) {
58
+ const type = isDirectory ? "d" : "-";
59
+ const owner = [
60
+ mode & 256 ? "r" : "-",
61
+ mode & 128 ? "w" : "-",
62
+ mode & 64 ? "x" : "-"
63
+ ].join("");
64
+ const group = [
65
+ mode & 32 ? "r" : "-",
66
+ mode & 16 ? "w" : "-",
67
+ mode & 8 ? "x" : "-"
68
+ ].join("");
69
+ const others = [
70
+ mode & 4 ? "r" : "-",
71
+ mode & 2 ? "w" : "-",
72
+ mode & 1 ? "x" : "-"
73
+ ].join("");
74
+ return `${type}${owner}${group}${others}`;
75
+ }
76
+ function formatDate(date) {
77
+ const year = date.getFullYear();
78
+ const month = String(date.getMonth() + 1).padStart(2, "0");
79
+ const day = String(date.getDate()).padStart(2, "0");
80
+ const hours = String(date.getHours()).padStart(2, "0");
81
+ const minutes = String(date.getMinutes()).padStart(2, "0");
82
+ const seconds = String(date.getSeconds()).padStart(2, "0");
83
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
84
+ }
85
+ function isPermissionError(error) {
86
+ return typeof error === "object" && error !== null && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
87
+ }
88
+ function getDirectoryListingCmd(filesystem, path, dir, showHidden) {
89
+ return async () => {
90
+ try {
91
+ const resolvedDir = path.resolve(dir);
92
+ const entries = await filesystem.readdir(resolvedDir, {
93
+ withFileTypes: true
94
+ });
95
+ if (entries.length > 0 && typeof entries[0] === "string") {
96
+ throw new Error(
97
+ "Filesystem adapter must support withFileTypes option"
98
+ );
99
+ }
100
+ const typedEntries = entries;
101
+ const items = [];
102
+ for (const entry of typedEntries) {
103
+ if (!showHidden && entry.name.startsWith(".")) {
104
+ continue;
105
+ }
106
+ const itemPath = path.join(resolvedDir, entry.name);
107
+ try {
108
+ const stats = await filesystem.stat(itemPath);
109
+ const extension = entry.isDirectory() ? "" : path.extname(entry.name);
110
+ const dateStr = formatDate(stats.mtime);
111
+ const perms = formatPermissions(stats.mode, entry.isDirectory());
112
+ const size = convertBytesToSizeString(stats.size);
113
+ const details = `${dateStr} ${perms} ${size}`;
114
+ items.push({
115
+ name: entry.name,
116
+ details,
117
+ path: itemPath,
118
+ extension,
119
+ isDirectory: entry.isDirectory(),
120
+ currentDirectory: resolvedDir,
121
+ mode: stats.mode
122
+ });
123
+ } catch (error) {
124
+ if (isPermissionError(error)) {
125
+ continue;
126
+ }
127
+ throw error;
128
+ }
129
+ }
130
+ items.sort((a, b) => {
131
+ if (a.isDirectory !== b.isDirectory) {
132
+ return a.isDirectory ? -1 : 1;
133
+ }
134
+ return a.name.localeCompare(b.name);
135
+ });
136
+ return new GetDirectoryListingMsg(items);
137
+ } catch (error) {
138
+ return new ErrorMsg(
139
+ error instanceof Error ? error : new Error(String(error))
140
+ );
141
+ }
142
+ };
143
+ }
144
+
145
+ // src/model.ts
146
+ var FiletreeModel = class _FiletreeModel {
147
+ /** Current cursor position */
148
+ cursor;
149
+ /** Array of directory items */
150
+ files;
151
+ /** Whether component is active and receives input */
152
+ active;
153
+ /** Key bindings */
154
+ keyMap;
155
+ /** Minimum viewport scroll position */
156
+ min;
157
+ /** Maximum viewport scroll position */
158
+ max;
159
+ /** Component height */
160
+ height;
161
+ /** Component width */
162
+ width;
163
+ /** Styles */
164
+ styles;
165
+ /** Current directory */
166
+ currentDir;
167
+ /** Whether to show hidden files */
168
+ showHidden;
169
+ /** Last error, if any */
170
+ error;
171
+ /** Filesystem adapter */
172
+ filesystem;
173
+ /** Path adapter */
174
+ path;
175
+ constructor(cursor, files, active, keyMap, min, max, height, width, styles, currentDir, showHidden, error, filesystem, path) {
176
+ this.cursor = cursor;
177
+ this.files = files;
178
+ this.active = active;
179
+ this.keyMap = keyMap;
180
+ this.min = min;
181
+ this.max = max;
182
+ this.height = height;
183
+ this.width = width;
184
+ this.styles = styles;
185
+ this.currentDir = currentDir;
186
+ this.showHidden = showHidden;
187
+ this.error = error;
188
+ this.filesystem = filesystem;
189
+ this.path = path;
190
+ }
191
+ /**
192
+ * Creates a new filetree model.
193
+ * @param options - Configuration options
194
+ * @returns A new FiletreeModel instance
195
+ * @public
196
+ */
197
+ static new(options) {
198
+ const currentDir = options.currentDir ?? options.filesystem.cwd();
199
+ const showHidden = options.showHidden ?? false;
200
+ const keyMap = options.keyMap ?? defaultKeyMap;
201
+ const styles = mergeStyles(options.styles);
202
+ const width = options.width ?? 80;
203
+ const height = options.height ?? 24;
204
+ return new _FiletreeModel(
205
+ 0,
206
+ // cursor
207
+ [],
208
+ // files
209
+ true,
210
+ // active
211
+ keyMap,
212
+ 0,
213
+ // min
214
+ height - 1,
215
+ // max
216
+ height,
217
+ width,
218
+ styles,
219
+ currentDir,
220
+ showHidden,
221
+ null,
222
+ // error
223
+ options.filesystem,
224
+ options.path
225
+ );
226
+ }
227
+ /**
228
+ * Initializes the model and returns a command to load the directory.
229
+ * @returns Command to load directory listing
230
+ * @public
231
+ */
232
+ init() {
233
+ return getDirectoryListingCmd(
234
+ this.filesystem,
235
+ this.path,
236
+ this.currentDir,
237
+ this.showHidden
238
+ );
239
+ }
240
+ /**
241
+ * Sets whether the component is active and receives input.
242
+ * @param active - Whether component should be active
243
+ * @returns Updated model
244
+ * @public
245
+ */
246
+ setIsActive(active) {
247
+ if (this.active === active) {
248
+ return this;
249
+ }
250
+ return new _FiletreeModel(
251
+ this.cursor,
252
+ this.files,
253
+ active,
254
+ this.keyMap,
255
+ this.min,
256
+ this.max,
257
+ this.height,
258
+ this.width,
259
+ this.styles,
260
+ this.currentDir,
261
+ this.showHidden,
262
+ this.error,
263
+ this.filesystem,
264
+ this.path
265
+ );
266
+ }
267
+ /**
268
+ * Updates the model in response to a message.
269
+ * @param msg - The message to handle
270
+ * @returns Tuple of updated model and command
271
+ * @public
272
+ */
273
+ update(msg) {
274
+ if (msg instanceof GetDirectoryListingMsg) {
275
+ const newMax = Math.max(
276
+ 0,
277
+ Math.min(this.height - 1, msg.items.length - 1)
278
+ );
279
+ return [
280
+ new _FiletreeModel(
281
+ 0,
282
+ // reset cursor to top
283
+ msg.items,
284
+ this.active,
285
+ this.keyMap,
286
+ 0,
287
+ newMax,
288
+ this.height,
289
+ this.width,
290
+ this.styles,
291
+ this.currentDir,
292
+ this.showHidden,
293
+ null,
294
+ // clear error
295
+ this.filesystem,
296
+ this.path
297
+ ),
298
+ null
299
+ ];
300
+ }
301
+ if (msg instanceof ErrorMsg) {
302
+ return [
303
+ new _FiletreeModel(
304
+ this.cursor,
305
+ this.files,
306
+ this.active,
307
+ this.keyMap,
308
+ this.min,
309
+ this.max,
310
+ this.height,
311
+ this.width,
312
+ this.styles,
313
+ this.currentDir,
314
+ this.showHidden,
315
+ msg.error,
316
+ this.filesystem,
317
+ this.path
318
+ ),
319
+ null
320
+ ];
321
+ }
322
+ if (msg instanceof tea.WindowSizeMsg) {
323
+ const newHeight = msg.height;
324
+ const newWidth = msg.width;
325
+ const newMax = Math.max(0, Math.min(newHeight - 1, this.files.length - 1));
326
+ const maxValidCursor = Math.max(0, this.files.length - 1);
327
+ const adjustedCursor = Math.min(
328
+ Math.min(this.cursor, maxValidCursor),
329
+ newMax
330
+ );
331
+ const newMin = Math.min(
332
+ Math.max(0, adjustedCursor - (newHeight - 1)),
333
+ adjustedCursor
334
+ );
335
+ return [
336
+ new _FiletreeModel(
337
+ adjustedCursor,
338
+ this.files,
339
+ this.active,
340
+ this.keyMap,
341
+ newMin,
342
+ newMax,
343
+ newHeight,
344
+ newWidth,
345
+ this.styles,
346
+ this.currentDir,
347
+ this.showHidden,
348
+ this.error,
349
+ this.filesystem,
350
+ this.path
351
+ ),
352
+ null
353
+ ];
354
+ }
355
+ if (!this.active) {
356
+ return [this, null];
357
+ }
358
+ if (msg instanceof tea.KeyMsg) {
359
+ if (key.matches(msg, this.keyMap.down)) {
360
+ if (this.files.length === 0) {
361
+ return [this, null];
362
+ }
363
+ const nextCursor = Math.min(this.cursor + 1, this.files.length - 1);
364
+ let nextMin = this.min;
365
+ let nextMax = this.max;
366
+ if (nextCursor > this.max) {
367
+ nextMin = this.min + 1;
368
+ nextMax = this.max + 1;
369
+ }
370
+ return [
371
+ new _FiletreeModel(
372
+ nextCursor,
373
+ this.files,
374
+ this.active,
375
+ this.keyMap,
376
+ nextMin,
377
+ nextMax,
378
+ this.height,
379
+ this.width,
380
+ this.styles,
381
+ this.currentDir,
382
+ this.showHidden,
383
+ this.error,
384
+ this.filesystem,
385
+ this.path
386
+ ),
387
+ null
388
+ ];
389
+ }
390
+ if (key.matches(msg, this.keyMap.up)) {
391
+ if (this.files.length === 0) {
392
+ return [this, null];
393
+ }
394
+ const nextCursor = Math.max(this.cursor - 1, 0);
395
+ let nextMin = this.min;
396
+ let nextMax = this.max;
397
+ if (nextCursor < this.min && this.min > 0) {
398
+ nextMin = this.min - 1;
399
+ nextMax = this.max - 1;
400
+ }
401
+ return [
402
+ new _FiletreeModel(
403
+ nextCursor,
404
+ this.files,
405
+ this.active,
406
+ this.keyMap,
407
+ nextMin,
408
+ nextMax,
409
+ this.height,
410
+ this.width,
411
+ this.styles,
412
+ this.currentDir,
413
+ this.showHidden,
414
+ this.error,
415
+ this.filesystem,
416
+ this.path
417
+ ),
418
+ null
419
+ ];
420
+ }
421
+ }
422
+ return [this, null];
423
+ }
424
+ /**
425
+ * Renders the file tree view.
426
+ * @returns Rendered string
427
+ * @public
428
+ */
429
+ view() {
430
+ if (this.error) {
431
+ return `Error: ${this.error.message}`;
432
+ }
433
+ if (this.files.length === 0) {
434
+ return "(empty directory)";
435
+ }
436
+ const lines = [];
437
+ for (let i = this.min; i <= this.max && i < this.files.length; i++) {
438
+ const item = this.files[i];
439
+ if (!item) continue;
440
+ const isSelected = i === this.cursor;
441
+ const style = isSelected ? this.styles.selectedItem : this.styles.normalItem;
442
+ const line = `${item.name} ${item.details}`;
443
+ lines.push(style.render(line));
444
+ }
445
+ const remainingLines = this.height - lines.length;
446
+ for (let i = 0; i < remainingLines; i++) {
447
+ lines.push("");
448
+ }
449
+ return lines.join("\n");
450
+ }
451
+ /**
452
+ * Gets the currently selected file, if any.
453
+ * @returns The selected DirectoryItem or null
454
+ * @public
455
+ */
456
+ get selectedFile() {
457
+ return this.files[this.cursor] ?? null;
458
+ }
459
+ };
460
+
461
+ exports.ErrorMsg = ErrorMsg;
462
+ exports.FiletreeModel = FiletreeModel;
463
+ exports.GetDirectoryListingMsg = GetDirectoryListingMsg;
464
+ exports.convertBytesToSizeString = convertBytesToSizeString;
465
+ exports.defaultKeyMap = defaultKeyMap;
466
+ exports.defaultStyles = defaultStyles;
467
+ exports.getDirectoryListingCmd = getDirectoryListingCmd;
468
+ exports.mergeStyles = mergeStyles;
469
+ //# sourceMappingURL=index.cjs.map
470
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/keymap.ts","../src/styles.ts","../src/messages.ts","../src/fs.ts","../src/model.ts"],"names":["newBinding","Style","WindowSizeMsg","KeyMsg","matches"],"mappings":"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","file":"index.cjs","sourcesContent":["import { Binding, newBinding } from '@suds-cli/key'\n\n/**\n * Keyboard bindings for the filetree component.\n * @public\n */\nexport interface FiletreeKeyMap {\n down: Binding\n up: Binding\n}\n\n/**\n * Default key bindings for the filetree.\n * @public\n */\nexport const defaultKeyMap: FiletreeKeyMap = {\n down: newBinding({ keys: ['j', 'down', 'ctrl+n'] }).withHelp('j/↓', 'down'),\n up: newBinding({ keys: ['k', 'up', 'ctrl+p'] }).withHelp('k/↑', 'up'),\n}\n","import { Style } from '@suds-cli/chapstick'\n\n/**\n * Style configuration for the filetree component.\n * @public\n */\nexport interface FiletreeStyles {\n /** Style for selected/highlighted item */\n selectedItem: Style\n /** Style for normal items */\n normalItem: Style\n}\n\n/**\n * Default styles for the filetree component.\n * @public\n */\nexport function defaultStyles(): FiletreeStyles {\n return {\n selectedItem: new Style().foreground('#00ff00').bold(true),\n normalItem: new Style(),\n }\n}\n\n/**\n * Merge user provided style overrides with defaults.\n * @public\n */\nexport function mergeStyles(\n overrides?: Partial<FiletreeStyles>,\n): FiletreeStyles {\n if (!overrides) {\n return defaultStyles()\n }\n return { ...defaultStyles(), ...overrides }\n}\n","import type { DirectoryItem } from './types.js'\n\n/**\n * Message containing directory listing results.\n * @public\n */\nexport class GetDirectoryListingMsg {\n readonly _tag = 'filetree-get-directory-listing'\n\n constructor(public readonly items: DirectoryItem[]) {}\n}\n\n/**\n * Message containing an error.\n * @public\n */\nexport class ErrorMsg {\n readonly _tag = 'filetree-error'\n\n constructor(public readonly error: Error) {}\n}\n","import type { Cmd } from '@suds-cli/tea'\nimport type {\n DirectoryEntry,\n FileSystemAdapter,\n PathAdapter,\n} from '@suds-cli/machine'\nimport type { DirectoryItem } from './types.js'\nimport { GetDirectoryListingMsg, ErrorMsg } from './messages.js'\n\n/**\n * Converts bytes to a human-readable size string.\n * @param size - Size in bytes\n * @returns Formatted string (e.g., \"1.2K\", \"5.4M\", \"2.3G\")\n * @public\n */\nexport function convertBytesToSizeString(size: number): string {\n if (size < 1024) {\n return `${size}B`\n }\n\n const units = ['K', 'M', 'G', 'T']\n let unitIndex = -1\n let adjustedSize = size\n\n while (adjustedSize >= 1024 && unitIndex < units.length - 1) {\n adjustedSize /= 1024\n unitIndex++\n }\n\n const unit = units[unitIndex]\n\n if (!unit) {\n return `${size}B`\n }\n\n // Format to 1 decimal place\n return `${adjustedSize.toFixed(1)}${unit}`\n}\n\n/**\n * Formats file permissions in Unix-style notation.\n * @param mode - File mode bits\n * @returns Permission string (e.g., \"-rw-r--r--\", \"drwxr-xr-x\")\n */\nfunction formatPermissions(mode: number, isDirectory: boolean): string {\n const type = isDirectory ? 'd' : '-'\n const owner = [\n mode & 0o400 ? 'r' : '-',\n mode & 0o200 ? 'w' : '-',\n mode & 0o100 ? 'x' : '-',\n ].join('')\n const group = [\n mode & 0o040 ? 'r' : '-',\n mode & 0o020 ? 'w' : '-',\n mode & 0o010 ? 'x' : '-',\n ].join('')\n const others = [\n mode & 0o004 ? 'r' : '-',\n mode & 0o002 ? 'w' : '-',\n mode & 0o001 ? 'x' : '-',\n ].join('')\n\n return `${type}${owner}${group}${others}`\n}\n\n/**\n * Formats a date for display.\n * @param date - Date to format\n * @returns Formatted date string\n */\nfunction formatDate(date: Date): string {\n const year = date.getFullYear()\n const month = String(date.getMonth() + 1).padStart(2, '0')\n const day = String(date.getDate()).padStart(2, '0')\n const hours = String(date.getHours()).padStart(2, '0')\n const minutes = String(date.getMinutes()).padStart(2, '0')\n const seconds = String(date.getSeconds()).padStart(2, '0')\n\n return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`\n}\n\n/**\n * Type guard to check if an error is a filesystem permission error.\n * @param error - The error to check\n * @returns True if the error is a permission-related filesystem error\n */\nfunction isPermissionError(\n error: unknown,\n): error is { code: 'EACCES' | 'EPERM' } {\n return (\n typeof error === 'object' &&\n error !== null &&\n 'code' in error &&\n ((error as { code: unknown }).code === 'EACCES' ||\n (error as { code: unknown }).code === 'EPERM')\n )\n}\n\n/**\n * Creates a command to asynchronously fetch directory contents.\n * @param filesystem - Filesystem adapter for file operations\n * @param path - Path adapter for path operations\n * @param dir - Directory path to list\n * @param showHidden - Whether to show hidden files\n * @returns Command that will emit GetDirectoryListingMsg or ErrorMsg\n * @public\n */\nexport function getDirectoryListingCmd(\n filesystem: FileSystemAdapter,\n path: PathAdapter,\n dir: string,\n showHidden: boolean,\n): Cmd<GetDirectoryListingMsg | ErrorMsg> {\n return async () => {\n try {\n const resolvedDir = path.resolve(dir)\n const entries = await filesystem.readdir(resolvedDir, {\n withFileTypes: true,\n })\n\n // Type guard: entries should be DirectoryEntry[] when withFileTypes is true\n if (\n entries.length > 0 &&\n typeof entries[0] === 'string'\n ) {\n // Fallback if adapter returns string[] instead of DirectoryEntry[]\n throw new Error(\n 'Filesystem adapter must support withFileTypes option',\n )\n }\n\n const typedEntries = entries as DirectoryEntry[]\n const items: DirectoryItem[] = []\n\n for (const entry of typedEntries) {\n // Skip hidden files if showHidden is false\n if (!showHidden && entry.name.startsWith('.')) {\n continue\n }\n\n const itemPath = path.join(resolvedDir, entry.name)\n\n try {\n const stats = await filesystem.stat(itemPath)\n const extension = entry.isDirectory() ? '' : path.extname(entry.name)\n\n // Format details: \"2024-01-15 10:30:00 -rw-r--r-- 1.2K\"\n const dateStr = formatDate(stats.mtime)\n const perms = formatPermissions(stats.mode, entry.isDirectory())\n const size = convertBytesToSizeString(stats.size)\n const details = `${dateStr} ${perms} ${size}`\n\n items.push({\n name: entry.name,\n details,\n path: itemPath,\n extension,\n isDirectory: entry.isDirectory(),\n currentDirectory: resolvedDir,\n mode: stats.mode,\n })\n } catch (error: unknown) {\n // Only skip permission-related errors; re-throw unexpected errors\n if (isPermissionError(error)) {\n // Skip files we can't stat due to permission issues\n // EACCES: Permission denied, EPERM: Operation not permitted\n continue\n }\n // Re-throw unexpected errors to surface real bugs\n throw error\n }\n }\n\n // Sort: directories first, then by name\n items.sort((a, b) => {\n if (a.isDirectory !== b.isDirectory) {\n return a.isDirectory ? -1 : 1\n }\n return a.name.localeCompare(b.name)\n })\n\n return new GetDirectoryListingMsg(items)\n } catch (error) {\n return new ErrorMsg(\n error instanceof Error ? error : new Error(String(error)),\n )\n }\n }\n}\n","import { matches } from '@suds-cli/key'\nimport { type Cmd, type Msg, KeyMsg, WindowSizeMsg } from '@suds-cli/tea'\nimport type { FileSystemAdapter, PathAdapter } from '@suds-cli/machine'\nimport type { DirectoryItem } from './types.js'\nimport { defaultKeyMap, type FiletreeKeyMap } from './keymap.js'\nimport { mergeStyles, type FiletreeStyles } from './styles.js'\nimport { getDirectoryListingCmd } from './fs.js'\nimport { GetDirectoryListingMsg, ErrorMsg } from './messages.js'\n\n/**\n * Options for creating a new FiletreeModel.\n * @public\n */\nexport interface FiletreeOptions {\n /** Filesystem adapter for file operations */\n filesystem: FileSystemAdapter\n /** Path adapter for path operations */\n path: PathAdapter\n /** Initial directory to display */\n currentDir?: string\n /** Whether to show hidden files */\n showHidden?: boolean\n /** Custom key bindings */\n keyMap?: FiletreeKeyMap\n /** Custom styles */\n styles?: Partial<FiletreeStyles>\n /** Initial width */\n width?: number\n /** Initial height */\n height?: number\n}\n\n/**\n * Model for the filetree component.\n * @public\n */\nexport class FiletreeModel {\n /** Current cursor position */\n readonly cursor: number\n\n /** Array of directory items */\n readonly files: DirectoryItem[]\n\n /** Whether component is active and receives input */\n readonly active: boolean\n\n /** Key bindings */\n readonly keyMap: FiletreeKeyMap\n\n /** Minimum viewport scroll position */\n readonly min: number\n\n /** Maximum viewport scroll position */\n readonly max: number\n\n /** Component height */\n readonly height: number\n\n /** Component width */\n readonly width: number\n\n /** Styles */\n readonly styles: FiletreeStyles\n\n /** Current directory */\n readonly currentDir: string\n\n /** Whether to show hidden files */\n readonly showHidden: boolean\n\n /** Last error, if any */\n readonly error: Error | null\n\n /** Filesystem adapter */\n readonly filesystem: FileSystemAdapter\n\n /** Path adapter */\n readonly path: PathAdapter\n\n private constructor(\n cursor: number,\n files: DirectoryItem[],\n active: boolean,\n keyMap: FiletreeKeyMap,\n min: number,\n max: number,\n height: number,\n width: number,\n styles: FiletreeStyles,\n currentDir: string,\n showHidden: boolean,\n error: Error | null,\n filesystem: FileSystemAdapter,\n path: PathAdapter,\n ) {\n this.cursor = cursor\n this.files = files\n this.active = active\n this.keyMap = keyMap\n this.min = min\n this.max = max\n this.height = height\n this.width = width\n this.styles = styles\n this.currentDir = currentDir\n this.showHidden = showHidden\n this.error = error\n this.filesystem = filesystem\n this.path = path\n }\n\n /**\n * Creates a new filetree model.\n * @param options - Configuration options\n * @returns A new FiletreeModel instance\n * @public\n */\n static new(options: FiletreeOptions): FiletreeModel {\n const currentDir = options.currentDir ?? options.filesystem.cwd()\n const showHidden = options.showHidden ?? false\n const keyMap = options.keyMap ?? defaultKeyMap\n const styles = mergeStyles(options.styles)\n const width = options.width ?? 80\n const height = options.height ?? 24\n\n return new FiletreeModel(\n 0, // cursor\n [], // files\n true, // active\n keyMap,\n 0, // min\n height - 1, // max\n height,\n width,\n styles,\n currentDir,\n showHidden,\n null, // error\n options.filesystem,\n options.path,\n )\n }\n\n /**\n * Initializes the model and returns a command to load the directory.\n * @returns Command to load directory listing\n * @public\n */\n init(): Cmd<Msg> {\n return getDirectoryListingCmd(\n this.filesystem,\n this.path,\n this.currentDir,\n this.showHidden,\n )\n }\n\n /**\n * Sets whether the component is active and receives input.\n * @param active - Whether component should be active\n * @returns Updated model\n * @public\n */\n setIsActive(active: boolean): FiletreeModel {\n if (this.active === active) {\n return this\n }\n\n return new FiletreeModel(\n this.cursor,\n this.files,\n active,\n this.keyMap,\n this.min,\n this.max,\n this.height,\n this.width,\n this.styles,\n this.currentDir,\n this.showHidden,\n this.error,\n this.filesystem,\n this.path,\n )\n }\n\n /**\n * Updates the model in response to a message.\n * @param msg - The message to handle\n * @returns Tuple of updated model and command\n * @public\n */\n update(msg: Msg): [FiletreeModel, Cmd<Msg> | null] {\n // Handle directory listing message\n if (msg instanceof GetDirectoryListingMsg) {\n const newMax = Math.max(\n 0,\n Math.min(this.height - 1, msg.items.length - 1),\n )\n return [\n new FiletreeModel(\n 0, // reset cursor to top\n msg.items,\n this.active,\n this.keyMap,\n 0,\n newMax,\n this.height,\n this.width,\n this.styles,\n this.currentDir,\n this.showHidden,\n null, // clear error\n this.filesystem,\n this.path,\n ),\n null,\n ]\n }\n\n // Handle error message\n if (msg instanceof ErrorMsg) {\n return [\n new FiletreeModel(\n this.cursor,\n this.files,\n this.active,\n this.keyMap,\n this.min,\n this.max,\n this.height,\n this.width,\n this.styles,\n this.currentDir,\n this.showHidden,\n msg.error,\n this.filesystem,\n this.path,\n ),\n null,\n ]\n }\n\n // Handle window size message\n if (msg instanceof WindowSizeMsg) {\n const newHeight = msg.height\n const newWidth = msg.width\n const newMax = Math.max(0, Math.min(newHeight - 1, this.files.length - 1))\n \n // Clamp cursor position to valid range and viewport bounds\n const maxValidCursor = Math.max(0, this.files.length - 1)\n const adjustedCursor = Math.min(\n Math.min(this.cursor, maxValidCursor),\n newMax,\n )\n \n // Adjust viewport min to keep cursor visible after resize\n // If cursor is at the bottom of the viewport, min should be adjusted\n const newMin = Math.min(\n Math.max(0, adjustedCursor - (newHeight - 1)),\n adjustedCursor,\n )\n\n return [\n new FiletreeModel(\n adjustedCursor,\n this.files,\n this.active,\n this.keyMap,\n newMin,\n newMax,\n newHeight,\n newWidth,\n this.styles,\n this.currentDir,\n this.showHidden,\n this.error,\n this.filesystem,\n this.path,\n ),\n null,\n ]\n }\n\n // Only handle keyboard input if active\n if (!this.active) {\n return [this, null]\n }\n\n // Handle keyboard input\n if (msg instanceof KeyMsg) {\n // Move down\n if (matches(msg, this.keyMap.down)) {\n // Don't navigate if files list is empty\n if (this.files.length === 0) {\n return [this, null]\n }\n const nextCursor = Math.min(this.cursor + 1, this.files.length - 1)\n\n // Adjust viewport if needed\n let nextMin = this.min\n let nextMax = this.max\n\n if (nextCursor > this.max) {\n nextMin = this.min + 1\n nextMax = this.max + 1\n }\n\n return [\n new FiletreeModel(\n nextCursor,\n this.files,\n this.active,\n this.keyMap,\n nextMin,\n nextMax,\n this.height,\n this.width,\n this.styles,\n this.currentDir,\n this.showHidden,\n this.error,\n this.filesystem,\n this.path,\n ),\n null,\n ]\n }\n\n // Move up\n if (matches(msg, this.keyMap.up)) {\n // Don't navigate if files list is empty\n if (this.files.length === 0) {\n return [this, null]\n }\n const nextCursor = Math.max(this.cursor - 1, 0)\n\n // Adjust viewport if needed\n let nextMin = this.min\n let nextMax = this.max\n\n if (nextCursor < this.min && this.min > 0) {\n nextMin = this.min - 1\n nextMax = this.max - 1\n }\n\n return [\n new FiletreeModel(\n nextCursor,\n this.files,\n this.active,\n this.keyMap,\n nextMin,\n nextMax,\n this.height,\n this.width,\n this.styles,\n this.currentDir,\n this.showHidden,\n this.error,\n this.filesystem,\n this.path,\n ),\n null,\n ]\n }\n }\n\n return [this, null]\n }\n\n /**\n * Renders the file tree view.\n * @returns Rendered string\n * @public\n */\n view(): string {\n if (this.error) {\n return `Error: ${this.error.message}`\n }\n\n if (this.files.length === 0) {\n return '(empty directory)'\n }\n\n const lines: string[] = []\n\n // Render visible items in viewport\n for (let i = this.min; i <= this.max && i < this.files.length; i++) {\n const item = this.files[i]\n if (!item) continue\n\n const isSelected = i === this.cursor\n const style = isSelected\n ? this.styles.selectedItem\n : this.styles.normalItem\n\n // Format: \"name details\"\n const line = `${item.name} ${item.details}`\n lines.push(style.render(line))\n }\n\n // Fill remaining height with empty lines\n const remainingLines = this.height - lines.length\n for (let i = 0; i < remainingLines; i++) {\n lines.push('')\n }\n\n return lines.join('\\n')\n }\n\n /**\n * Gets the currently selected file, if any.\n * @returns The selected DirectoryItem or null\n * @public\n */\n get selectedFile(): DirectoryItem | null {\n return this.files[this.cursor] ?? null\n }\n}\n"]}