@ecmaos/coreutils 0.3.0 → 0.4.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.
Files changed (69) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +68 -0
  3. package/dist/commands/cron.d.ts +4 -0
  4. package/dist/commands/cron.d.ts.map +1 -0
  5. package/dist/commands/cron.js +532 -0
  6. package/dist/commands/cron.js.map +1 -0
  7. package/dist/commands/env.d.ts +4 -0
  8. package/dist/commands/env.d.ts.map +1 -0
  9. package/dist/commands/env.js +129 -0
  10. package/dist/commands/env.js.map +1 -0
  11. package/dist/commands/head.d.ts.map +1 -1
  12. package/dist/commands/head.js +184 -77
  13. package/dist/commands/head.js.map +1 -1
  14. package/dist/commands/less.d.ts.map +1 -1
  15. package/dist/commands/less.js +1 -0
  16. package/dist/commands/less.js.map +1 -1
  17. package/dist/commands/man.d.ts.map +1 -1
  18. package/dist/commands/man.js +13 -1
  19. package/dist/commands/man.js.map +1 -1
  20. package/dist/commands/mount.d.ts +4 -0
  21. package/dist/commands/mount.d.ts.map +1 -0
  22. package/dist/commands/mount.js +1041 -0
  23. package/dist/commands/mount.js.map +1 -0
  24. package/dist/commands/open.d.ts +4 -0
  25. package/dist/commands/open.d.ts.map +1 -0
  26. package/dist/commands/open.js +74 -0
  27. package/dist/commands/open.js.map +1 -0
  28. package/dist/commands/play.d.ts +4 -0
  29. package/dist/commands/play.d.ts.map +1 -0
  30. package/dist/commands/play.js +231 -0
  31. package/dist/commands/play.js.map +1 -0
  32. package/dist/commands/tar.d.ts.map +1 -1
  33. package/dist/commands/tar.js +67 -17
  34. package/dist/commands/tar.js.map +1 -1
  35. package/dist/commands/umount.d.ts +4 -0
  36. package/dist/commands/umount.d.ts.map +1 -0
  37. package/dist/commands/umount.js +104 -0
  38. package/dist/commands/umount.js.map +1 -0
  39. package/dist/commands/video.d.ts +4 -0
  40. package/dist/commands/video.d.ts.map +1 -0
  41. package/dist/commands/video.js +250 -0
  42. package/dist/commands/video.js.map +1 -0
  43. package/dist/commands/view.d.ts +5 -0
  44. package/dist/commands/view.d.ts.map +1 -0
  45. package/dist/commands/view.js +830 -0
  46. package/dist/commands/view.js.map +1 -0
  47. package/dist/commands/web.d.ts +4 -0
  48. package/dist/commands/web.d.ts.map +1 -0
  49. package/dist/commands/web.js +348 -0
  50. package/dist/commands/web.js.map +1 -0
  51. package/dist/index.d.ts +9 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +29 -2
  54. package/dist/index.js.map +1 -1
  55. package/package.json +11 -2
  56. package/src/commands/cron.ts +591 -0
  57. package/src/commands/env.ts +143 -0
  58. package/src/commands/head.ts +176 -77
  59. package/src/commands/less.ts +1 -0
  60. package/src/commands/man.ts +12 -1
  61. package/src/commands/mount.ts +1193 -0
  62. package/src/commands/open.ts +84 -0
  63. package/src/commands/play.ts +249 -0
  64. package/src/commands/tar.ts +62 -19
  65. package/src/commands/umount.ts +117 -0
  66. package/src/commands/video.ts +267 -0
  67. package/src/commands/view.ts +916 -0
  68. package/src/commands/web.ts +377 -0
  69. package/src/index.ts +29 -2
@@ -0,0 +1,916 @@
1
+ import path from 'path'
2
+ import chalk from 'chalk'
3
+ import { marked } from 'marked'
4
+ import '@alenaksu/json-viewer'
5
+ import type { Kernel, Process, Shell, Terminal } from '@ecmaos/types'
6
+ import { TerminalCommand } from '../shared/terminal-command.js'
7
+ import { writelnStderr, writelnStdout } from '../shared/helpers.js'
8
+
9
+ type FileType = 'pdf' | 'image' | 'audio' | 'video' | 'markdown' | 'json' | 'application/octet-stream'
10
+
11
+ function detectFileType(filePath: string): FileType {
12
+ const ext = path.extname(filePath).toLowerCase()
13
+
14
+ // PDF
15
+ if (ext === '.pdf') return 'pdf'
16
+
17
+ // Markdown
18
+ const markdownExts = ['.md', '.markdown', '.mdown', '.mkd', '.mkdn']
19
+ if (markdownExts.includes(ext)) return 'markdown'
20
+
21
+ // JSON
22
+ const jsonExts = ['.json', '.jsonl', '.jsonc', '.jsonld']
23
+ if (jsonExts.includes(ext)) return 'json'
24
+
25
+ // Images
26
+ const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico', '.tiff', '.tif']
27
+ if (imageExts.includes(ext)) return 'image'
28
+
29
+ // Audio
30
+ const audioExts = ['.mp3', '.wav', '.ogg', '.oga', '.opus', '.m4a', '.aac', '.flac', '.webm', '.wma', '.aiff', '.aif', '.3gp', '.amr']
31
+ if (audioExts.includes(ext)) return 'audio'
32
+
33
+ // Video
34
+ const videoExts = ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv', '.m4v', '.flv', '.wmv', '.3gp']
35
+ if (videoExts.includes(ext)) return 'video'
36
+
37
+ return 'application/octet-stream'
38
+ }
39
+
40
+ function getMimeType(filePath: string, fileType: FileType): string {
41
+ const ext = path.extname(filePath).toLowerCase()
42
+
43
+ if (fileType === 'pdf') return 'application/pdf'
44
+
45
+ if (fileType === 'image') {
46
+ const mimeTypes: Record<string, string> = {
47
+ '.jpg': 'image/jpeg',
48
+ '.jpeg': 'image/jpeg',
49
+ '.png': 'image/png',
50
+ '.gif': 'image/gif',
51
+ '.bmp': 'image/bmp',
52
+ '.webp': 'image/webp',
53
+ '.svg': 'image/svg+xml',
54
+ '.ico': 'image/x-icon',
55
+ '.tiff': 'image/tiff',
56
+ '.tif': 'image/tiff'
57
+ }
58
+ return mimeTypes[ext] || 'image/png'
59
+ }
60
+
61
+ if (fileType === 'audio') {
62
+ const mimeTypes: Record<string, string> = {
63
+ '.mp3': 'audio/mpeg',
64
+ '.wav': 'audio/wav',
65
+ '.ogg': 'audio/ogg',
66
+ '.oga': 'audio/ogg',
67
+ '.opus': 'audio/opus',
68
+ '.m4a': 'audio/mp4',
69
+ '.aac': 'audio/aac',
70
+ '.flac': 'audio/flac',
71
+ '.webm': 'audio/webm',
72
+ '.wma': 'audio/x-ms-wma',
73
+ '.aiff': 'audio/aiff',
74
+ '.aif': 'audio/aiff',
75
+ '.3gp': 'audio/3gpp',
76
+ '.amr': 'audio/amr'
77
+ }
78
+ return mimeTypes[ext] || 'audio/mpeg'
79
+ }
80
+
81
+ if (fileType === 'video') {
82
+ const mimeTypes: Record<string, string> = {
83
+ '.mp4': 'video/mp4',
84
+ '.webm': 'video/webm',
85
+ '.ogg': 'video/ogg',
86
+ '.ogv': 'video/ogg',
87
+ '.mov': 'video/quicktime',
88
+ '.avi': 'video/x-msvideo',
89
+ '.mkv': 'video/x-matroska',
90
+ '.m4v': 'video/mp4',
91
+ '.flv': 'video/x-flv',
92
+ '.wmv': 'video/x-ms-wmv',
93
+ '.3gp': 'video/3gpp'
94
+ }
95
+ return mimeTypes[ext] || 'video/mp4'
96
+ }
97
+
98
+ if (fileType === 'json') return 'application/json'
99
+
100
+ return 'application/octet-stream'
101
+ }
102
+
103
+ function printUsage(process: Process | undefined, terminal: Terminal): void {
104
+ const usage = `Usage: view [OPTIONS] [FILE...]
105
+ View files in a new window. Supports PDF, markdown, JSON, images, audio, and video files.
106
+
107
+ --help display this help and exit
108
+
109
+ Audio/Video Options (for audio and video files):
110
+ --no-autoplay don't start playing automatically
111
+ --loop loop the media
112
+ --muted start muted
113
+ --volume <0-100> set volume (0-100, default: 100, audio only)
114
+ --no-controls hide video controls (video only)
115
+ --fullscreen open in fullscreen mode (video only)
116
+ --width <width> set window width (video only)
117
+ --height <height> set window height (video only)
118
+ --quiet play without opening a window (audio only, background playback)
119
+
120
+ Examples:
121
+ view document.pdf view a PDF file
122
+ view README.md view a markdown file
123
+ view data.json view a JSON file
124
+ view image.png view an image
125
+ view song.mp3 view/play an audio file
126
+ view movie.mp4 view/play a video file
127
+ view --loop music.mp3 play audio in a loop
128
+ view --no-autoplay video.mp4 load video without auto-playing
129
+ view --volume 50 track.mp3 play at 50% volume
130
+ view --fullscreen movie.mp4 play video in fullscreen mode`
131
+ writelnStderr(process, terminal, usage)
132
+ }
133
+
134
+ async function loadAudioMetadata(audioElement: HTMLAudioElement): Promise<{ duration: number }> {
135
+ return new Promise((resolve, reject) => {
136
+ const timeout = setTimeout(() => {
137
+ reject(new Error('Timeout loading audio metadata'))
138
+ }, 10000)
139
+
140
+ audioElement.onloadedmetadata = () => {
141
+ clearTimeout(timeout)
142
+ resolve({
143
+ duration: audioElement.duration
144
+ })
145
+ }
146
+
147
+ audioElement.onerror = () => {
148
+ clearTimeout(timeout)
149
+ reject(new Error('Failed to load audio metadata'))
150
+ }
151
+ })
152
+ }
153
+
154
+ async function loadVideoMetadata(videoElement: HTMLVideoElement): Promise<{ width: number; height: number; duration: number }> {
155
+ return new Promise((resolve, reject) => {
156
+ const timeout = setTimeout(() => {
157
+ reject(new Error('Timeout loading video metadata'))
158
+ }, 10000)
159
+
160
+ videoElement.onloadedmetadata = () => {
161
+ clearTimeout(timeout)
162
+ resolve({
163
+ width: videoElement.videoWidth,
164
+ height: videoElement.videoHeight,
165
+ duration: videoElement.duration
166
+ })
167
+ }
168
+
169
+ videoElement.onerror = () => {
170
+ clearTimeout(timeout)
171
+ reject(new Error('Failed to load video metadata'))
172
+ }
173
+ })
174
+ }
175
+
176
+ function formatDuration(seconds: number): string {
177
+ if (!isFinite(seconds) || isNaN(seconds)) return '?:??'
178
+ const hours = Math.floor(seconds / 3600)
179
+ const minutes = Math.floor((seconds % 3600) / 60)
180
+ const secs = Math.floor(seconds % 60)
181
+
182
+ if (hours > 0) {
183
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
184
+ }
185
+ return `${minutes}:${secs.toString().padStart(2, '0')}`
186
+ }
187
+
188
+ function generateRandomClass(prefix: string): string {
189
+ const randomSuffix = Math.random().toString(36).substring(2, 8)
190
+ return `${prefix}-${randomSuffix}`
191
+ }
192
+
193
+ export function createCommand(kernel: Kernel, shell: Shell, terminal: Terminal): TerminalCommand {
194
+ return new TerminalCommand({
195
+ command: 'view',
196
+ description: 'View files in a new window (PDF, images, audio, video)',
197
+ kernel,
198
+ shell,
199
+ terminal,
200
+ run: async (pid: number, argv: string[]) => {
201
+ const process = kernel.processes.get(pid) as Process | undefined
202
+
203
+ if (!process) return 1
204
+
205
+ if (argv.length > 0 && (argv[0] === '--help' || argv[0] === '-h')) {
206
+ printUsage(process, terminal)
207
+ return 0
208
+ }
209
+
210
+ // Parse options
211
+ const options: {
212
+ autoplay: boolean
213
+ loop: boolean
214
+ muted: boolean
215
+ volume: number
216
+ controls: boolean
217
+ fullscreen: boolean
218
+ quiet: boolean
219
+ width?: number
220
+ height?: number
221
+ } = {
222
+ autoplay: true,
223
+ loop: false,
224
+ muted: false,
225
+ volume: 100,
226
+ controls: true,
227
+ fullscreen: false,
228
+ quiet: false
229
+ }
230
+
231
+ const files: string[] = []
232
+
233
+ for (let i = 0; i < argv.length; i++) {
234
+ const arg = argv[i]
235
+ if (arg === '--no-autoplay') {
236
+ options.autoplay = false
237
+ } else if (arg === '--loop') {
238
+ options.loop = true
239
+ } else if (arg === '--muted') {
240
+ options.muted = true
241
+ } else if (arg === '--quiet') {
242
+ options.quiet = true
243
+ } else if (arg === '--no-controls') {
244
+ options.controls = false
245
+ } else if (arg === '--fullscreen') {
246
+ options.fullscreen = true
247
+ } else if (arg === '--volume' && i + 1 < argv.length) {
248
+ const volumeArg = argv[i + 1]
249
+ if (!volumeArg) {
250
+ await writelnStderr(process, terminal, chalk.red(`view: missing volume value`))
251
+ return 1
252
+ }
253
+ const volume = parseFloat(volumeArg)
254
+ if (isNaN(volume) || volume < 0 || volume > 100) {
255
+ await writelnStderr(process, terminal, chalk.red(`view: invalid volume: ${volumeArg} (must be 0-100)`))
256
+ return 1
257
+ }
258
+ options.volume = volume
259
+ i++ // Skip next argument
260
+ } else if (arg === '--width' && i + 1 < argv.length) {
261
+ const widthArg = argv[i + 1]
262
+ if (!widthArg) {
263
+ await writelnStderr(process, terminal, chalk.red(`view: missing width value`))
264
+ return 1
265
+ }
266
+ const width = parseInt(widthArg, 10)
267
+ if (isNaN(width) || width <= 0) {
268
+ await writelnStderr(process, terminal, chalk.red(`view: invalid width: ${widthArg}`))
269
+ return 1
270
+ }
271
+ options.width = width
272
+ i++ // Skip next argument
273
+ } else if (arg === '--height' && i + 1 < argv.length) {
274
+ const heightArg = argv[i + 1]
275
+ if (!heightArg) {
276
+ await writelnStderr(process, terminal, chalk.red(`view: missing height value`))
277
+ return 1
278
+ }
279
+ const height = parseInt(heightArg, 10)
280
+ if (isNaN(height) || height <= 0) {
281
+ await writelnStderr(process, terminal, chalk.red(`view: invalid height: ${heightArg}`))
282
+ return 1
283
+ }
284
+ options.height = height
285
+ i++ // Skip next argument
286
+ } else if (arg && !arg.startsWith('--')) {
287
+ files.push(arg)
288
+ }
289
+ }
290
+
291
+ if (files.length === 0) {
292
+ await writelnStderr(process, terminal, `view: missing file argument`)
293
+ await writelnStderr(process, terminal, `Try 'view --help' for more information.`)
294
+ return 1
295
+ }
296
+
297
+ // Process each file
298
+ for (const file of files) {
299
+ const fullPath = path.resolve(shell.cwd, file)
300
+
301
+ try {
302
+ // Check if file exists
303
+ if (!(await shell.context.fs.promises.exists(fullPath))) {
304
+ await writelnStderr(process, terminal, chalk.red(`view: file not found: ${fullPath}`))
305
+ continue
306
+ }
307
+
308
+ // Read file
309
+ await writelnStdout(process, terminal, chalk.blue(`Loading: ${file}...`))
310
+ const fileData = await shell.context.fs.promises.readFile(fullPath)
311
+ const fileType = detectFileType(fullPath)
312
+ const mimeType = getMimeType(fullPath, fileType)
313
+
314
+ const windowTitle = files.length > 1
315
+ ? `${path.basename(file)} (${files.indexOf(file) + 1}/${files.length})`
316
+ : path.basename(file)
317
+
318
+ if (fileType === 'markdown') {
319
+ // Read and parse markdown file
320
+ const markdownText = new TextDecoder().decode(fileData)
321
+ const htmlContent = await marked.parse(markdownText)
322
+
323
+ const containerClass = generateRandomClass('markdown-container')
324
+
325
+ const container = document.createElement('div')
326
+ container.className = containerClass
327
+ container.style.width = '100%'
328
+ container.style.height = '100%'
329
+ container.style.display = 'flex'
330
+ container.style.flexDirection = 'column'
331
+ container.style.background = '#1e1e1e'
332
+ container.style.overflow = 'auto'
333
+ container.style.padding = '40px'
334
+ container.style.boxSizing = 'border-box'
335
+ container.style.fontFamily = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif"
336
+ container.style.color = '#e0e0e0'
337
+ container.style.lineHeight = '1.6'
338
+
339
+ const contentWrapper = document.createElement('div')
340
+ contentWrapper.style.maxWidth = '800px'
341
+ contentWrapper.style.margin = '0 auto'
342
+ contentWrapper.style.width = '100%'
343
+ contentWrapper.innerHTML = htmlContent
344
+
345
+ const style = document.createElement('style')
346
+ style.textContent = `
347
+ .${containerClass} h1, .${containerClass} h2, .${containerClass} h3, .${containerClass} h4, .${containerClass} h5, .${containerClass} h6 { color: #fff; margin-top: 1.5em; margin-bottom: 0.5em; }
348
+ .${containerClass} h1 { font-size: 2em; border-bottom: 1px solid #444; padding-bottom: 0.3em; }
349
+ .${containerClass} h2 { font-size: 1.5em; border-bottom: 1px solid #444; padding-bottom: 0.3em; }
350
+ .${containerClass} h3 { font-size: 1.25em; }
351
+ .${containerClass} code { background-color: #2d2d2d; padding: 2px 6px; border-radius: 3px; font-family: 'Courier New', monospace; color: #f8f8f2; }
352
+ .${containerClass} pre { background-color: #2d2d2d; padding: 16px; border-radius: 6px; overflow-x: auto; }
353
+ .${containerClass} pre code { background-color: transparent; padding: 0; }
354
+ .${containerClass} a { color: #4a9eff; text-decoration: none; }
355
+ .${containerClass} a:hover { text-decoration: underline; }
356
+ .${containerClass} blockquote { border-left: 4px solid #4a9eff; padding-left: 16px; margin-left: 0; color: #b0b0b0; }
357
+ .${containerClass} table { border-collapse: collapse; width: 100%; margin: 1em 0; }
358
+ .${containerClass} th, .${containerClass} td { border: 1px solid #444; padding: 8px 12px; text-align: left; }
359
+ .${containerClass} th { background-color: #2d2d2d; font-weight: bold; }
360
+ .${containerClass} tr:nth-child(even) { background-color: #252525; }
361
+ .${containerClass} img { max-width: 100%; height: auto; }
362
+ .${containerClass} ul, .${containerClass} ol { padding-left: 2em; }
363
+ .${containerClass} hr { border: none; border-top: 1px solid #444; margin: 2em 0; }
364
+ `
365
+
366
+ container.appendChild(style)
367
+ container.appendChild(contentWrapper)
368
+
369
+ const win = kernel.windows.create({
370
+ title: windowTitle,
371
+ width: 900,
372
+ height: 700,
373
+ max: false
374
+ })
375
+
376
+ win.mount(container)
377
+
378
+ await writelnStdout(process, terminal, chalk.green(`Viewing: ${file}`))
379
+ } else if (fileType === 'json') {
380
+ // Read and parse JSON file
381
+ const jsonText = new TextDecoder().decode(fileData)
382
+ let jsonData: unknown
383
+
384
+ try {
385
+ jsonData = JSON.parse(jsonText)
386
+ } catch (error) {
387
+ await writelnStderr(process, terminal, chalk.red(`view: invalid JSON in ${file}: ${error instanceof Error ? error.message : 'Unknown error'}`))
388
+ continue
389
+ }
390
+
391
+ type JsonViewerElement = HTMLElement & {
392
+ data?: unknown
393
+ filter(regexOrPath: RegExp | string): void
394
+ resetFilter(): void
395
+ expand(regexOrPath: RegExp | string): void
396
+ expandAll(): void
397
+ collapse(regexOrPath: RegExp | string): void
398
+ collapseAll(): void
399
+ }
400
+
401
+ const containerClass = generateRandomClass('json-container')
402
+
403
+ const container = document.createElement('div')
404
+ container.className = containerClass
405
+ container.style.width = '100%'
406
+ container.style.height = '100%'
407
+ container.style.display = 'flex'
408
+ container.style.flexDirection = 'column'
409
+ container.style.background = '#2a2f3a'
410
+ container.style.overflow = 'hidden'
411
+
412
+ const buttonBar = document.createElement('div')
413
+ buttonBar.style.cssText = `
414
+ display: flex;
415
+ flex-wrap: wrap;
416
+ gap: 6px;
417
+ padding: 6px;
418
+ background: #2a2f3a;
419
+ border-bottom: 1px solid #3c3c3c;
420
+ align-items: center;
421
+ `
422
+
423
+ const createInput = (placeholder: string): HTMLInputElement => {
424
+ const input = document.createElement('input')
425
+ input.type = 'text'
426
+ input.placeholder = placeholder
427
+ input.style.cssText = `
428
+ background: #263040;
429
+ border: 1px solid #3c3c3c;
430
+ border-radius: 3px;
431
+ color: #fff;
432
+ padding: 0 8px;
433
+ font-size: 11px;
434
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
435
+ width: 120px;
436
+ height: 22px;
437
+ box-sizing: border-box;
438
+ line-height: 22px;
439
+ margin: 0;
440
+ vertical-align: middle;
441
+ `
442
+ return input
443
+ }
444
+
445
+ const createButton = (text: string): HTMLButtonElement => {
446
+ const button = document.createElement('button')
447
+ button.textContent = text
448
+ button.style.cssText = `
449
+ background: #263040;
450
+ border: 1px solid #263040;
451
+ border-radius: 3px;
452
+ color: #fff;
453
+ cursor: pointer;
454
+ font-size: 11px;
455
+ font-weight: 600;
456
+ padding: 0;
457
+ height: 22px;
458
+ box-sizing: border-box;
459
+ line-height: 22px;
460
+ white-space: nowrap;
461
+ display: flex;
462
+ align-items: center;
463
+ justify-content: center;
464
+ padding-left: 8px;
465
+ padding-right: 8px;
466
+ margin: 0;
467
+ vertical-align: middle;
468
+ `
469
+ button.onmouseenter = () => {
470
+ button.style.background = '#333'
471
+ button.style.borderColor = '#333'
472
+ }
473
+ button.onmouseleave = () => {
474
+ button.style.background = '#263040'
475
+ button.style.borderColor = '#263040'
476
+ }
477
+ button.onmousedown = () => {
478
+ button.style.background = '#263040'
479
+ }
480
+ button.onmouseup = () => {
481
+ button.style.background = '#333'
482
+ }
483
+ return button
484
+ }
485
+
486
+ const createSection = (label: string): HTMLDivElement => {
487
+ const section = document.createElement('div')
488
+ section.style.cssText = `
489
+ display: flex;
490
+ align-items: center;
491
+ gap: 4px;
492
+ height: 22px;
493
+ `
494
+ const labelEl = document.createElement('span')
495
+ labelEl.textContent = label
496
+ labelEl.style.cssText = `
497
+ color: #f8f8f2;
498
+ font-size: 11px;
499
+ font-weight: 600;
500
+ width: 50px;
501
+ flex-shrink: 0;
502
+ line-height: 22px;
503
+ display: flex;
504
+ align-items: center;
505
+ height: 22px;
506
+ `
507
+ section.appendChild(labelEl)
508
+ return section
509
+ }
510
+
511
+ const filterSection = createSection('Filter:')
512
+ const filterInput = createInput('Regex or path')
513
+ filterSection.appendChild(filterInput)
514
+
515
+ const expandSection = createSection('Expand:')
516
+ const expandInput = createInput('Regex or path')
517
+ const expandButton = createButton('Expand')
518
+ const expandAllButton = createButton('Expand All')
519
+ expandSection.appendChild(expandInput)
520
+ expandSection.appendChild(expandButton)
521
+ expandSection.appendChild(expandAllButton)
522
+
523
+ const collapseSection = createSection('Collapse:')
524
+ const collapseInput = createInput('Regex or path')
525
+ const collapseButton = createButton('Collapse')
526
+ const collapseAllButton = createButton('Collapse All')
527
+ collapseSection.appendChild(collapseInput)
528
+ collapseSection.appendChild(collapseButton)
529
+ collapseSection.appendChild(collapseAllButton)
530
+
531
+ buttonBar.appendChild(filterSection)
532
+ buttonBar.appendChild(expandSection)
533
+ buttonBar.appendChild(collapseSection)
534
+
535
+ const jsonViewer = document.createElement('json-viewer') as JsonViewerElement
536
+ jsonViewer.style.width = '100%'
537
+ jsonViewer.style.flex = '1'
538
+ jsonViewer.style.padding = '0.5rem'
539
+ jsonViewer.style.overflow = 'auto'
540
+ jsonViewer.data = jsonData
541
+
542
+ jsonViewer.style.setProperty('--background-color', '#2a2f3a')
543
+ jsonViewer.style.setProperty('--color', '#f8f8f2')
544
+ jsonViewer.style.setProperty('--font-family', "'Courier New', monospace")
545
+ jsonViewer.style.setProperty('--string-color', '#a3eea0')
546
+ jsonViewer.style.setProperty('--number-color', '#d19a66')
547
+ jsonViewer.style.setProperty('--boolean-color', '#4ba7ef')
548
+ jsonViewer.style.setProperty('--null-color', '#df9cf3')
549
+ jsonViewer.style.setProperty('--property-color', '#6fb3d2')
550
+ jsonViewer.style.setProperty('--preview-color', '#deae8f')
551
+ jsonViewer.style.setProperty('--highlight-color', '#c92a2a')
552
+
553
+ filterInput.addEventListener('input', () => {
554
+ const value = filterInput.value.trim()
555
+ if (value) {
556
+ try {
557
+ const regex = new RegExp(value)
558
+ jsonViewer.filter(regex)
559
+ } catch {
560
+ jsonViewer.filter(value)
561
+ }
562
+ } else {
563
+ jsonViewer.resetFilter()
564
+ }
565
+ })
566
+
567
+ expandButton.onclick = () => {
568
+ const value = expandInput.value.trim()
569
+ if (value) {
570
+ try {
571
+ const regex = new RegExp(value)
572
+ jsonViewer.expand(regex)
573
+ } catch {
574
+ jsonViewer.expand(value)
575
+ }
576
+ }
577
+ }
578
+
579
+ expandAllButton.onclick = () => {
580
+ jsonViewer.expandAll()
581
+ }
582
+
583
+ collapseButton.onclick = () => {
584
+ const value = collapseInput.value.trim()
585
+ if (value) {
586
+ try {
587
+ const regex = new RegExp(value)
588
+ jsonViewer.collapse(regex)
589
+ } catch {
590
+ jsonViewer.collapse(value)
591
+ }
592
+ }
593
+ }
594
+
595
+ collapseAllButton.onclick = () => {
596
+ jsonViewer.collapseAll()
597
+ }
598
+
599
+ const handleEnterKey = (input: HTMLInputElement, button: HTMLButtonElement) => {
600
+ input.addEventListener('keydown', (e) => {
601
+ if (e.key === 'Enter') {
602
+ button.click()
603
+ }
604
+ })
605
+ }
606
+
607
+ handleEnterKey(expandInput, expandButton)
608
+ handleEnterKey(collapseInput, collapseButton)
609
+
610
+ container.appendChild(buttonBar)
611
+ container.appendChild(jsonViewer)
612
+
613
+ // Create window
614
+ const win = kernel.windows.create({
615
+ title: windowTitle,
616
+ width: 900,
617
+ height: 700,
618
+ max: false
619
+ })
620
+
621
+ win.mount(container)
622
+ await writelnStdout(process, terminal, chalk.green(`Viewing: ${file}`))
623
+ } else if (fileType === 'pdf') {
624
+ // Convert PDF to base64 and display in object tag
625
+ // Use chunked encoding to avoid argument limit issues with large files
626
+ const uint8Array = new Uint8Array(fileData)
627
+ let binaryString = ''
628
+ const chunkSize = 8192
629
+ for (let i = 0; i < uint8Array.length; i += chunkSize) {
630
+ const chunk = uint8Array.subarray(i, i + chunkSize)
631
+ binaryString += String.fromCharCode(...chunk)
632
+ }
633
+ const base64Data = btoa(binaryString)
634
+ const dataUrl = `data:application/pdf;base64,${base64Data}`
635
+
636
+ const containerClass = generateRandomClass('pdf-container')
637
+
638
+ const container = document.createElement('div')
639
+ container.className = containerClass
640
+ container.style.width = '100%'
641
+ container.style.height = '100%'
642
+ container.style.display = 'flex'
643
+ container.style.flexDirection = 'column'
644
+ container.style.background = '#1e1e1e'
645
+ container.style.overflow = 'hidden'
646
+
647
+ const object = document.createElement('object')
648
+ object.data = dataUrl
649
+ object.type = 'application/pdf'
650
+ object.style.width = '100%'
651
+ object.style.height = '100%'
652
+ object.style.flex = '1'
653
+
654
+ const fallback = document.createElement('p')
655
+ fallback.style.color = '#fff'
656
+ fallback.style.padding = '20px'
657
+ fallback.style.textAlign = 'center'
658
+ fallback.textContent = 'Your browser does not support PDFs. '
659
+
660
+ const downloadLink = document.createElement('a')
661
+ downloadLink.href = dataUrl
662
+ downloadLink.download = path.basename(file)
663
+ downloadLink.style.color = '#4a9eff'
664
+ downloadLink.textContent = 'Download PDF'
665
+
666
+ fallback.appendChild(downloadLink)
667
+ object.appendChild(fallback)
668
+ container.appendChild(object)
669
+
670
+ const win = kernel.windows.create({
671
+ title: windowTitle,
672
+ width: 800,
673
+ height: 600,
674
+ max: false
675
+ })
676
+
677
+ win.mount(container)
678
+
679
+ await writelnStdout(process, terminal, chalk.green(`Viewing: ${file}`))
680
+ } else if (fileType === 'image') {
681
+ // Display image directly
682
+ const blob = new Blob([new Uint8Array(fileData)], { type: mimeType })
683
+ const url = URL.createObjectURL(blob)
684
+
685
+ const containerClass = generateRandomClass('image-container')
686
+
687
+ const container = document.createElement('div')
688
+ container.className = containerClass
689
+ container.style.width = '100%'
690
+ container.style.height = '100%'
691
+ container.style.display = 'flex'
692
+ container.style.alignItems = 'center'
693
+ container.style.justifyContent = 'center'
694
+ container.style.background = '#1e1e1e'
695
+ container.style.overflow = 'auto'
696
+ container.style.padding = '20px'
697
+ container.style.boxSizing = 'border-box'
698
+
699
+ const img = document.createElement('img')
700
+ img.src = url
701
+ img.alt = path.basename(file)
702
+ img.style.maxWidth = '100%'
703
+ img.style.maxHeight = '100%'
704
+ img.style.objectFit = 'contain'
705
+
706
+ container.appendChild(img)
707
+
708
+ const win = kernel.windows.create({
709
+ title: windowTitle,
710
+ width: 800,
711
+ height: 600,
712
+ max: false
713
+ })
714
+
715
+ win.mount(container)
716
+
717
+ await writelnStdout(process, terminal, chalk.green(`Viewing: ${file}`))
718
+ } else if (fileType === 'audio') {
719
+ // Handle audio similar to play command
720
+ const blob = new Blob([new Uint8Array(fileData)], { type: mimeType })
721
+ const url = URL.createObjectURL(blob)
722
+
723
+ // Load audio metadata
724
+ const audioElement = document.createElement('audio')
725
+ audioElement.src = url
726
+ audioElement.preload = 'metadata'
727
+
728
+ let duration = 0
729
+
730
+ try {
731
+ const metadata = await loadAudioMetadata(audioElement)
732
+ duration = metadata.duration
733
+ } catch (error) {
734
+ await writelnStderr(process, terminal, chalk.yellow(`view: warning: could not load metadata for ${file}`))
735
+ }
736
+
737
+ // Set audio properties
738
+ audioElement.volume = options.volume / 100
739
+ if (options.autoplay) audioElement.autoplay = true
740
+ if (options.loop) audioElement.loop = true
741
+ if (options.muted) audioElement.muted = true
742
+
743
+ if (options.quiet) {
744
+ // Background playback - no window
745
+ audioElement.play().catch((error) => {
746
+ // Autoplay may be blocked by browser, but that's okay for quiet mode
747
+ console.warn('Autoplay blocked:', error)
748
+ })
749
+
750
+ if (duration > 0) {
751
+ const durationStr = formatDuration(duration)
752
+ await writelnStdout(process, terminal, chalk.green(`Playing in background: ${file} (${durationStr})`))
753
+ } else {
754
+ await writelnStdout(process, terminal, chalk.green(`Playing in background: ${file}`))
755
+ }
756
+ } else {
757
+ // Create a simple audio player window
758
+ const audioId = `audio-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
759
+ const containerClass = generateRandomClass('audio-container')
760
+
761
+ const container = document.createElement('div')
762
+ container.className = containerClass
763
+ container.style.width = '100%'
764
+ container.style.height = '100%'
765
+ container.style.display = 'flex'
766
+ container.style.flexDirection = 'column'
767
+ container.style.alignItems = 'center'
768
+ container.style.justifyContent = 'center'
769
+ container.style.background = '#1e1e1e'
770
+ container.style.color = '#fff'
771
+ container.style.fontFamily = 'monospace'
772
+ container.style.padding = '20px'
773
+ container.style.boxSizing = 'border-box'
774
+
775
+ const title = document.createElement('div')
776
+ title.textContent = path.basename(file)
777
+ title.style.fontSize = '18px'
778
+ title.style.marginBottom = '20px'
779
+ title.style.textAlign = 'center'
780
+ title.style.wordBreak = 'break-word'
781
+
782
+ const audio = document.createElement('audio')
783
+ audio.id = audioId
784
+ audio.src = url
785
+ audio.controls = true
786
+ audio.style.width = '100%'
787
+ audio.style.maxWidth = '600px'
788
+
789
+ if (options.autoplay) audio.autoplay = true
790
+ if (options.loop) audio.loop = true
791
+ if (options.muted) audio.muted = true
792
+
793
+ audio.volume = options.volume / 100
794
+ audio.addEventListener('play', () => console.log(`Playing: ${file}`))
795
+ audio.addEventListener('ended', () => console.log(`Finished: ${file}`))
796
+
797
+ container.appendChild(title)
798
+ container.appendChild(audio)
799
+
800
+ const win = kernel.windows.create({
801
+ title: windowTitle,
802
+ width: 500,
803
+ height: 200,
804
+ max: false
805
+ })
806
+
807
+ win.mount(container)
808
+
809
+ if (duration > 0) {
810
+ const durationStr = formatDuration(duration)
811
+ await writelnStdout(process, terminal, chalk.green(`Playing: ${file} (${durationStr})`))
812
+ } else {
813
+ await writelnStdout(process, terminal, chalk.green(`Playing: ${file}`))
814
+ }
815
+ }
816
+ } else if (fileType === 'video') {
817
+ // Handle video similar to video command
818
+ const blob = new Blob([new Uint8Array(fileData)], { type: mimeType })
819
+ const url = URL.createObjectURL(blob)
820
+
821
+ // Load video metadata
822
+ const videoElement = document.createElement('video')
823
+ videoElement.src = url
824
+ videoElement.preload = 'metadata'
825
+
826
+ let videoWidth: number
827
+ let videoHeight: number
828
+ let duration: number
829
+
830
+ try {
831
+ const metadata = await loadVideoMetadata(videoElement)
832
+ videoWidth = metadata.width
833
+ videoHeight = metadata.height
834
+ duration = metadata.duration
835
+ } catch (error) {
836
+ await writelnStderr(process, terminal, chalk.yellow(`view: warning: could not load metadata for ${file}, using default size`))
837
+ videoWidth = 640
838
+ videoHeight = 360
839
+ duration = 0
840
+ }
841
+
842
+ // Calculate window dimensions
843
+ const { innerWidth, innerHeight } = window
844
+ let windowWidth: number
845
+ let windowHeight: number
846
+
847
+ if (options.fullscreen) {
848
+ windowWidth = innerWidth
849
+ windowHeight = innerHeight
850
+ } else if (options.width && options.height) {
851
+ windowWidth = options.width
852
+ windowHeight = options.height
853
+ } else if (options.width) {
854
+ windowWidth = options.width
855
+ windowHeight = Math.round((videoHeight / videoWidth) * windowWidth)
856
+ } else if (options.height) {
857
+ windowHeight = options.height
858
+ windowWidth = Math.round((videoWidth / videoHeight) * windowHeight)
859
+ } else {
860
+ // Auto-size: fit to screen if video is larger, otherwise use video dimensions
861
+ const scale = Math.min(innerWidth / videoWidth, innerHeight / videoHeight, 1)
862
+ windowWidth = Math.round(videoWidth * scale)
863
+ windowHeight = Math.round(videoHeight * scale)
864
+ }
865
+
866
+ // Ensure minimum size
867
+ windowWidth = Math.max(windowWidth, 320)
868
+ windowHeight = Math.max(windowHeight, 180)
869
+
870
+ const containerClass = generateRandomClass('video-container')
871
+
872
+ const container = document.createElement('div')
873
+ container.className = containerClass
874
+ container.style.width = '100%'
875
+ container.style.height = '100%'
876
+
877
+ const video = document.createElement('video')
878
+ video.src = url
879
+ video.style.width = '100%'
880
+ video.style.height = '100%'
881
+ video.style.objectFit = 'contain'
882
+
883
+ if (options.autoplay) video.autoplay = true
884
+ if (options.controls) video.controls = true
885
+ if (options.loop) video.loop = true
886
+ if (options.muted) video.muted = true
887
+
888
+ container.appendChild(video)
889
+
890
+ const win = kernel.windows.create({
891
+ title: windowTitle,
892
+ width: windowWidth,
893
+ height: windowHeight,
894
+ max: options.fullscreen
895
+ })
896
+
897
+ win.mount(container)
898
+
899
+ if (duration > 0) {
900
+ const minutes = Math.floor(duration / 60)
901
+ const seconds = Math.floor(duration % 60)
902
+ await writelnStdout(process, terminal, chalk.green(`Playing: ${file} (${minutes}:${seconds.toString().padStart(2, '0')})`))
903
+ } else {
904
+ await writelnStdout(process, terminal, chalk.green(`Playing: ${file}`))
905
+ }
906
+ }
907
+ } catch (error) {
908
+ await writelnStderr(process, terminal, chalk.red(`view: error viewing ${file}: ${error instanceof Error ? error.message : 'Unknown error'}`))
909
+ return 1
910
+ }
911
+ }
912
+
913
+ return 0
914
+ }
915
+ })
916
+ }