@backbay/glia-desktop 0.2.0-alpha.1

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 (58) hide show
  1. package/package.json +37 -0
  2. package/src/components/GliaErrorBoundary/GliaErrorBoundary.tsx +202 -0
  3. package/src/components/GliaErrorBoundary/index.ts +2 -0
  4. package/src/components/GliaErrorBoundary/useErrorBoundary.tsx +61 -0
  5. package/src/components/desktop/Desktop.tsx +204 -0
  6. package/src/components/desktop/DesktopIcon.tsx +293 -0
  7. package/src/components/desktop/FileBrowser.stories.tsx +287 -0
  8. package/src/components/desktop/FileBrowser.tsx +981 -0
  9. package/src/components/desktop/SnapZoneOverlay.tsx +230 -0
  10. package/src/components/desktop/index.ts +15 -0
  11. package/src/components/index.ts +16 -0
  12. package/src/components/shell/Clock.tsx +212 -0
  13. package/src/components/shell/ContextMenu.tsx +249 -0
  14. package/src/components/shell/GlassMenubar.stories.tsx +382 -0
  15. package/src/components/shell/GlassMenubar.tsx +632 -0
  16. package/src/components/shell/NotificationCenter.stories.tsx +515 -0
  17. package/src/components/shell/NotificationCenter.tsx +545 -0
  18. package/src/components/shell/NotificationToast.tsx +319 -0
  19. package/src/components/shell/StartMenu.stories.tsx +249 -0
  20. package/src/components/shell/StartMenu.tsx +568 -0
  21. package/src/components/shell/SystemTray.stories.tsx +492 -0
  22. package/src/components/shell/SystemTray.tsx +457 -0
  23. package/src/components/shell/Taskbar.tsx +387 -0
  24. package/src/components/shell/TaskbarButton.tsx +208 -0
  25. package/src/components/shell/index.ts +37 -0
  26. package/src/components/window/Window.tsx +751 -0
  27. package/src/components/window/WindowTitlebar.tsx +359 -0
  28. package/src/components/window/index.ts +10 -0
  29. package/src/core/desktop/fileBrowserTypes.ts +112 -0
  30. package/src/core/desktop/index.ts +8 -0
  31. package/src/core/desktop/types.ts +185 -0
  32. package/src/core/desktop/useFileBrowser.tsx +405 -0
  33. package/src/core/desktop/useSnapZones.tsx +203 -0
  34. package/src/core/index.ts +11 -0
  35. package/src/core/shell/__tests__/useNotifications.test.ts +155 -0
  36. package/src/core/shell/__tests__/useTaskbar.test.ts +99 -0
  37. package/src/core/shell/index.ts +10 -0
  38. package/src/core/shell/notificationTypes.ts +110 -0
  39. package/src/core/shell/types.ts +194 -0
  40. package/src/core/shell/useNotifications.tsx +259 -0
  41. package/src/core/shell/useStartMenu.tsx +242 -0
  42. package/src/core/shell/useSystemTray.tsx +175 -0
  43. package/src/core/shell/useTaskbar.tsx +320 -0
  44. package/src/core/useKeyboardNavigation.ts +41 -0
  45. package/src/core/window/__tests__/useWindowManager.test.ts +269 -0
  46. package/src/core/window/index.ts +6 -0
  47. package/src/core/window/types.ts +149 -0
  48. package/src/core/window/useWindowManager.tsx +1154 -0
  49. package/src/index.ts +146 -0
  50. package/src/lib/utils.ts +6 -0
  51. package/src/providers/DesktopOSProvider.tsx +391 -0
  52. package/src/providers/ThemeProvider.tsx +162 -0
  53. package/src/providers/index.ts +6 -0
  54. package/src/themes/default.ts +107 -0
  55. package/src/themes/index.ts +6 -0
  56. package/src/themes/types.ts +230 -0
  57. package/tsconfig.json +20 -0
  58. package/tsup.config.ts +16 -0
@@ -0,0 +1,981 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * @backbay/glia Desktop OS - FileBrowser Component
5
+ *
6
+ * A file browser with grid/list views, toolbar, breadcrumb navigation,
7
+ * keyboard navigation, and context menu integration.
8
+ *
9
+ * Uses CSS variables for theming (--glia-color-*, --glia-font-*, etc.)
10
+ */
11
+
12
+ import React, { useCallback, useEffect, useRef, useMemo, useState } from 'react';
13
+ import { useFileBrowser } from '../../core/desktop/useFileBrowser';
14
+ import type {
15
+ FileItem,
16
+ FileBrowserProps,
17
+ FileBrowserSortField,
18
+ } from '../../core/desktop/fileBrowserTypes';
19
+ import type { ContextMenuItem } from '../../core/shell/types';
20
+
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+ // Default Icons
23
+ // ═══════════════════════════════════════════════════════════════════════════
24
+
25
+ const FILE_TYPE_ICONS: Record<string, string> = {
26
+ folder: '\u{1F4C1}',
27
+ file: '\u{1F4C4}',
28
+ image: '\u{1F5BC}\uFE0F',
29
+ document: '\u{1F4C3}',
30
+ code: '\u{1F4BB}',
31
+ archive: '\u{1F4E6}',
32
+ audio: '\u{1F3B5}',
33
+ video: '\u{1F3AC}',
34
+ };
35
+
36
+ function getFileIcon(file: FileItem): React.ReactNode {
37
+ if (file.icon) return file.icon;
38
+ return FILE_TYPE_ICONS[file.type] ?? FILE_TYPE_ICONS.file;
39
+ }
40
+
41
+ // ═══════════════════════════════════════════════════════════════════════════
42
+ // Format Helpers
43
+ // ═══════════════════════════════════════════════════════════════════════════
44
+
45
+ function formatFileSize(bytes?: number): string {
46
+ if (bytes == null) return '\u2014';
47
+ if (bytes === 0) return '0 B';
48
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
49
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
50
+ const val = bytes / Math.pow(1024, i);
51
+ return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
52
+ }
53
+
54
+ function formatDate(timestamp?: number): string {
55
+ if (timestamp == null) return '\u2014';
56
+ const d = new Date(timestamp);
57
+ return d.toLocaleDateString(undefined, {
58
+ year: 'numeric',
59
+ month: 'short',
60
+ day: 'numeric',
61
+ });
62
+ }
63
+
64
+ // ═══════════════════════════════════════════════════════════════════════════
65
+ // Styles
66
+ // ═══════════════════════════════════════════════════════════════════════════
67
+
68
+ const styles: Record<string, React.CSSProperties> = {
69
+ container: {
70
+ display: 'flex',
71
+ flexDirection: 'column',
72
+ height: '100%',
73
+ background: 'var(--glia-color-bg-panel, #0a0a0a)',
74
+ color: 'var(--glia-color-text-muted, #cccccc)',
75
+ fontFamily: 'var(--glia-font-mono, "JetBrains Mono", monospace)',
76
+ fontSize: '11px',
77
+ overflow: 'hidden',
78
+ outline: 'none',
79
+ },
80
+
81
+ // Toolbar
82
+ toolbar: {
83
+ display: 'flex',
84
+ alignItems: 'center',
85
+ gap: '6px',
86
+ height: '36px',
87
+ minHeight: '36px',
88
+ padding: '0 8px',
89
+ background: 'var(--glia-color-bg-panel, #111111)',
90
+ borderBottom: '1px solid var(--glia-color-border, #333333)',
91
+ },
92
+ navButton: {
93
+ display: 'flex',
94
+ alignItems: 'center',
95
+ justifyContent: 'center',
96
+ width: '24px',
97
+ height: '24px',
98
+ border: 'none',
99
+ borderRadius: 'var(--glia-radius-sm, 3px)',
100
+ background: 'transparent',
101
+ color: 'var(--glia-color-text-muted, #cccccc)',
102
+ cursor: 'pointer',
103
+ fontFamily: 'var(--glia-font-mono, monospace)',
104
+ fontSize: '12px',
105
+ transition: 'all 0.15s ease',
106
+ },
107
+ navButtonDisabled: {
108
+ opacity: 0.3,
109
+ cursor: 'default',
110
+ pointerEvents: 'none' as const,
111
+ },
112
+ toolbarSeparator: {
113
+ width: '1px',
114
+ height: '16px',
115
+ background: 'var(--glia-color-border, #333333)',
116
+ margin: '0 2px',
117
+ },
118
+ viewToggle: {
119
+ display: 'flex',
120
+ alignItems: 'center',
121
+ justifyContent: 'center',
122
+ width: '24px',
123
+ height: '24px',
124
+ border: 'none',
125
+ borderRadius: 'var(--glia-radius-sm, 3px)',
126
+ background: 'transparent',
127
+ color: 'var(--glia-color-text-muted, #cccccc)',
128
+ cursor: 'pointer',
129
+ fontFamily: 'var(--glia-font-mono, monospace)',
130
+ fontSize: '11px',
131
+ transition: 'all 0.15s ease',
132
+ },
133
+ viewToggleActive: {
134
+ background: 'rgba(212, 168, 75, 0.12)',
135
+ color: 'var(--glia-color-accent, #d4a84b)',
136
+ },
137
+ searchInput: {
138
+ marginLeft: 'auto',
139
+ height: '22px',
140
+ width: '140px',
141
+ padding: '0 6px',
142
+ border: '1px solid var(--glia-color-border, #333333)',
143
+ borderRadius: 'var(--glia-radius-sm, 3px)',
144
+ background: 'rgba(0, 0, 0, 0.3)',
145
+ color: 'var(--glia-color-text-primary, #ffffff)',
146
+ fontFamily: 'var(--glia-font-mono, monospace)',
147
+ fontSize: '10px',
148
+ outline: 'none',
149
+ transition: 'border-color 0.15s ease',
150
+ },
151
+
152
+ // Breadcrumb
153
+ breadcrumb: {
154
+ display: 'flex',
155
+ alignItems: 'center',
156
+ gap: '2px',
157
+ padding: '4px 8px',
158
+ fontFamily: 'var(--glia-font-mono, "JetBrains Mono", monospace)',
159
+ fontSize: '11px',
160
+ borderBottom: '1px solid var(--glia-color-border, #222222)',
161
+ overflow: 'hidden',
162
+ },
163
+ breadcrumbSegment: {
164
+ border: 'none',
165
+ background: 'transparent',
166
+ color: 'var(--glia-color-text-soft, #888888)',
167
+ fontFamily: 'var(--glia-font-mono, monospace)',
168
+ fontSize: '11px',
169
+ cursor: 'pointer',
170
+ padding: '2px 4px',
171
+ borderRadius: '2px',
172
+ transition: 'color 0.15s ease',
173
+ whiteSpace: 'nowrap' as const,
174
+ },
175
+ breadcrumbSegmentActive: {
176
+ color: 'var(--glia-color-text-primary, #ffffff)',
177
+ cursor: 'default',
178
+ },
179
+ breadcrumbSeparator: {
180
+ color: 'var(--glia-color-text-soft, #555555)',
181
+ fontSize: '9px',
182
+ userSelect: 'none' as const,
183
+ },
184
+
185
+ // Grid View
186
+ gridContainer: {
187
+ flex: 1,
188
+ overflow: 'auto',
189
+ padding: '12px',
190
+ },
191
+ grid: {
192
+ display: 'grid',
193
+ gridTemplateColumns: 'repeat(auto-fill, 88px)',
194
+ gap: '8px',
195
+ alignContent: 'start',
196
+ },
197
+ gridItem: {
198
+ display: 'flex',
199
+ flexDirection: 'column',
200
+ alignItems: 'center',
201
+ gap: '6px',
202
+ padding: '8px 10px',
203
+ minWidth: '88px',
204
+ borderRadius: 'var(--glia-radius-sm, 3px)',
205
+ background: 'transparent',
206
+ border: '1px solid transparent',
207
+ cursor: 'pointer',
208
+ transition: 'all 350ms ease-out',
209
+ outline: 'none',
210
+ },
211
+ gridItemSelected: {
212
+ background: 'var(--glia-glass-hover-bg, rgba(212, 168, 75, 0.08))',
213
+ boxShadow: '0 0 24px 8px rgba(212, 168, 75, 0.12), 0 0 48px 16px rgba(212, 168, 75, 0.06)',
214
+ },
215
+ gridItemIcon: {
216
+ width: '48px',
217
+ height: '48px',
218
+ display: 'flex',
219
+ alignItems: 'center',
220
+ justifyContent: 'center',
221
+ fontSize: '26px',
222
+ transition: 'all 350ms ease-out',
223
+ },
224
+ gridItemLabel: {
225
+ fontFamily: 'var(--glia-font-display, "Cinzel", serif)',
226
+ fontSize: '10px',
227
+ fontWeight: 600,
228
+ letterSpacing: '0.06em',
229
+ textTransform: 'uppercase' as const,
230
+ textAlign: 'center' as const,
231
+ whiteSpace: 'nowrap' as const,
232
+ maxWidth: '100%',
233
+ overflow: 'hidden',
234
+ textOverflow: 'ellipsis',
235
+ lineHeight: 1.2,
236
+ transition: 'color 350ms ease-out',
237
+ },
238
+
239
+ // List View
240
+ listContainer: {
241
+ flex: 1,
242
+ overflow: 'auto',
243
+ },
244
+ listTable: {
245
+ width: '100%',
246
+ borderCollapse: 'collapse' as const,
247
+ },
248
+ listHeader: {
249
+ position: 'sticky' as const,
250
+ top: 0,
251
+ background: 'var(--glia-color-bg-panel, #111111)',
252
+ zIndex: 1,
253
+ },
254
+ listHeaderCell: {
255
+ padding: '0 12px',
256
+ height: '28px',
257
+ fontFamily: 'var(--glia-font-mono, monospace)',
258
+ fontSize: '10px',
259
+ fontWeight: 600,
260
+ letterSpacing: '0.1em',
261
+ textTransform: 'uppercase' as const,
262
+ color: 'var(--glia-color-text-soft, #888888)',
263
+ textAlign: 'left' as const,
264
+ borderBottom: '1px solid var(--glia-color-border, #333333)',
265
+ cursor: 'pointer',
266
+ userSelect: 'none' as const,
267
+ whiteSpace: 'nowrap' as const,
268
+ transition: 'color 0.15s ease',
269
+ },
270
+ listHeaderCellActive: {
271
+ color: 'var(--glia-color-accent, #d4a84b)',
272
+ },
273
+ listRow: {
274
+ height: '28px',
275
+ borderBottom: '1px solid var(--glia-color-border, #1a1a1a)',
276
+ cursor: 'pointer',
277
+ transition: 'background 0.1s ease',
278
+ },
279
+ listRowSelected: {
280
+ background: 'var(--glia-glass-hover-bg, rgba(212, 168, 75, 0.08))',
281
+ },
282
+ listCell: {
283
+ padding: '0 12px',
284
+ whiteSpace: 'nowrap' as const,
285
+ overflow: 'hidden',
286
+ textOverflow: 'ellipsis',
287
+ maxWidth: '300px',
288
+ },
289
+ listCellName: {
290
+ display: 'flex',
291
+ alignItems: 'center',
292
+ gap: '8px',
293
+ },
294
+ listCellIcon: {
295
+ fontSize: '14px',
296
+ width: '18px',
297
+ textAlign: 'center' as const,
298
+ flexShrink: 0,
299
+ },
300
+ listCellSize: {
301
+ textAlign: 'right' as const,
302
+ color: 'var(--glia-color-text-soft, #888888)',
303
+ },
304
+ listCellDate: {
305
+ color: 'var(--glia-color-text-soft, #888888)',
306
+ },
307
+ listCellType: {
308
+ color: 'var(--glia-color-text-soft, #888888)',
309
+ textTransform: 'uppercase' as const,
310
+ fontSize: '10px',
311
+ letterSpacing: '0.05em',
312
+ },
313
+
314
+ // Status Bar
315
+ statusBar: {
316
+ display: 'flex',
317
+ alignItems: 'center',
318
+ gap: '12px',
319
+ height: '24px',
320
+ minHeight: '24px',
321
+ padding: '0 10px',
322
+ borderTop: '1px solid var(--glia-color-border, #333333)',
323
+ fontFamily: 'var(--glia-font-mono, monospace)',
324
+ fontSize: '10px',
325
+ color: 'var(--glia-color-text-soft, #888888)',
326
+ },
327
+
328
+ // Empty state
329
+ empty: {
330
+ flex: 1,
331
+ display: 'flex',
332
+ alignItems: 'center',
333
+ justifyContent: 'center',
334
+ color: 'var(--glia-color-text-soft, #555555)',
335
+ fontFamily: 'var(--glia-font-mono, monospace)',
336
+ fontSize: '11px',
337
+ letterSpacing: '0.1em',
338
+ textTransform: 'uppercase' as const,
339
+ },
340
+
341
+ // Sort arrow
342
+ sortArrow: {
343
+ marginLeft: '4px',
344
+ fontSize: '8px',
345
+ },
346
+ };
347
+
348
+ // ═══════════════════════════════════════════════════════════════════════════
349
+ // Sub-components
350
+ // ═══════════════════════════════════════════════════════════════════════════
351
+
352
+ interface ToolbarProps {
353
+ canGoBack: boolean;
354
+ canGoForward: boolean;
355
+ canGoUp: boolean;
356
+ viewMode: 'grid' | 'list';
357
+ searchQuery: string;
358
+ onBack: () => void;
359
+ onForward: () => void;
360
+ onUp: () => void;
361
+ onViewMode: (mode: 'grid' | 'list') => void;
362
+ onSearchChange: (query: string) => void;
363
+ }
364
+
365
+ function Toolbar({
366
+ canGoBack,
367
+ canGoForward,
368
+ canGoUp,
369
+ viewMode,
370
+ searchQuery,
371
+ onBack,
372
+ onForward,
373
+ onUp,
374
+ onViewMode,
375
+ onSearchChange,
376
+ }: ToolbarProps) {
377
+ return (
378
+ <div style={styles.toolbar} data-bb-filebrowser-toolbar>
379
+ <button
380
+ type="button"
381
+ style={{ ...styles.navButton, ...(!canGoBack ? styles.navButtonDisabled : {}) }}
382
+ onClick={onBack}
383
+ disabled={!canGoBack}
384
+ title="Back"
385
+ aria-label="Navigate back"
386
+ >
387
+ &#x2190;
388
+ </button>
389
+ <button
390
+ type="button"
391
+ style={{ ...styles.navButton, ...(!canGoForward ? styles.navButtonDisabled : {}) }}
392
+ onClick={onForward}
393
+ disabled={!canGoForward}
394
+ title="Forward"
395
+ aria-label="Navigate forward"
396
+ >
397
+ &#x2192;
398
+ </button>
399
+ <button
400
+ type="button"
401
+ style={{ ...styles.navButton, ...(!canGoUp ? styles.navButtonDisabled : {}) }}
402
+ onClick={onUp}
403
+ disabled={!canGoUp}
404
+ title="Up"
405
+ aria-label="Navigate up"
406
+ >
407
+ &#x2191;
408
+ </button>
409
+
410
+ <div style={styles.toolbarSeparator} />
411
+
412
+ <button
413
+ type="button"
414
+ style={{ ...styles.viewToggle, ...(viewMode === 'grid' ? styles.viewToggleActive : {}) }}
415
+ onClick={() => onViewMode('grid')}
416
+ title="Grid view"
417
+ aria-label="Grid view"
418
+ >
419
+ &#x2593;
420
+ </button>
421
+ <button
422
+ type="button"
423
+ style={{ ...styles.viewToggle, ...(viewMode === 'list' ? styles.viewToggleActive : {}) }}
424
+ onClick={() => onViewMode('list')}
425
+ title="List view"
426
+ aria-label="List view"
427
+ >
428
+ &#x2261;
429
+ </button>
430
+
431
+ <input
432
+ type="text"
433
+ style={styles.searchInput}
434
+ placeholder="SEARCH..."
435
+ value={searchQuery}
436
+ onChange={(e) => onSearchChange(e.target.value)}
437
+ data-bb-filebrowser-search
438
+ />
439
+ </div>
440
+ );
441
+ }
442
+
443
+ interface BreadcrumbProps {
444
+ breadcrumb: (FileItem | null)[];
445
+ onNavigate: (folderId: string | null) => void;
446
+ }
447
+
448
+ function Breadcrumb({ breadcrumb, onNavigate }: BreadcrumbProps) {
449
+ return (
450
+ <div style={styles.breadcrumb} data-bb-filebrowser-breadcrumb>
451
+ {breadcrumb.map((item, i) => {
452
+ const isLast = i === breadcrumb.length - 1;
453
+ const label = item === null ? 'ROOT' : item.name;
454
+ const id = item === null ? null : item.id;
455
+
456
+ return (
457
+ <React.Fragment key={id ?? 'root'}>
458
+ {i > 0 && <span style={styles.breadcrumbSeparator}>&gt;</span>}
459
+ <button
460
+ type="button"
461
+ style={{
462
+ ...styles.breadcrumbSegment,
463
+ ...(isLast ? styles.breadcrumbSegmentActive : {}),
464
+ }}
465
+ onClick={() => { if (!isLast) onNavigate(id); }}
466
+ disabled={isLast}
467
+ >
468
+ {label}
469
+ </button>
470
+ </React.Fragment>
471
+ );
472
+ })}
473
+ </div>
474
+ );
475
+ }
476
+
477
+ interface GridViewProps {
478
+ files: FileItem[];
479
+ selectedIds: Set<string>;
480
+ focusedIndex: number;
481
+ onSelect: (id: string, additive: boolean) => void;
482
+ onActivate: (file: FileItem) => void;
483
+ onContextMenu: (e: React.MouseEvent, file: FileItem | null) => void;
484
+ }
485
+
486
+ function GridView({ files, selectedIds, focusedIndex, onSelect, onActivate, onContextMenu }: GridViewProps) {
487
+ if (files.length === 0) {
488
+ return <div style={styles.empty}>NO ITEMS</div>;
489
+ }
490
+
491
+ return (
492
+ <div style={styles.gridContainer} onContextMenu={(e) => { e.preventDefault(); onContextMenu(e, null); }}>
493
+ <div style={styles.grid} data-bb-filebrowser-grid>
494
+ {files.map((file, i) => {
495
+ const isSelected = selectedIds.has(file.id);
496
+ const isFocused = i === focusedIndex;
497
+
498
+ return (
499
+ <button
500
+ key={file.id}
501
+ type="button"
502
+ style={{
503
+ ...styles.gridItem,
504
+ ...(isSelected ? styles.gridItemSelected : {}),
505
+ ...(isFocused && !isSelected ? { outline: '1px solid var(--glia-color-accent, #d4a84b)', outlineOffset: '-1px' } : {}),
506
+ }}
507
+ onClick={(e) => onSelect(file.id, e.metaKey || e.ctrlKey)}
508
+ onDoubleClick={() => onActivate(file)}
509
+ onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onSelect(file.id, false); onContextMenu(e, file); }}
510
+ data-bb-filebrowser-item
511
+ data-id={file.id}
512
+ data-selected={isSelected || undefined}
513
+ >
514
+ <div
515
+ style={{
516
+ ...styles.gridItemIcon,
517
+ color: isSelected ? 'var(--glia-color-accent, #d4a84b)' : 'var(--glia-color-text-muted, #cccccc)',
518
+ textShadow: isSelected
519
+ ? '0 0 16px rgba(212, 168, 75, 0.35), 0 0 32px rgba(212, 168, 75, 0.2)'
520
+ : 'none',
521
+ }}
522
+ >
523
+ {getFileIcon(file)}
524
+ </div>
525
+ <span
526
+ style={{
527
+ ...styles.gridItemLabel,
528
+ color: isSelected ? 'var(--glia-color-accent, #d4a84b)' : 'var(--glia-color-text-muted, #cccccc)',
529
+ }}
530
+ >
531
+ {file.name}
532
+ </span>
533
+ </button>
534
+ );
535
+ })}
536
+ </div>
537
+ </div>
538
+ );
539
+ }
540
+
541
+ interface ListViewProps {
542
+ files: FileItem[];
543
+ selectedIds: Set<string>;
544
+ focusedIndex: number;
545
+ sort: { field: FileBrowserSortField; order: 'asc' | 'desc' };
546
+ onSelect: (id: string, additive: boolean) => void;
547
+ onActivate: (file: FileItem) => void;
548
+ onToggleSort: (field: FileBrowserSortField) => void;
549
+ onContextMenu: (e: React.MouseEvent, file: FileItem | null) => void;
550
+ }
551
+
552
+ function ListView({ files, selectedIds, focusedIndex, sort, onSelect, onActivate, onToggleSort, onContextMenu }: ListViewProps) {
553
+ const renderSortArrow = (field: FileBrowserSortField) => {
554
+ if (sort.field !== field) return null;
555
+ return <span style={styles.sortArrow}>{sort.order === 'asc' ? '\u25B2' : '\u25BC'}</span>;
556
+ };
557
+
558
+ if (files.length === 0) {
559
+ return <div style={styles.empty}>NO ITEMS</div>;
560
+ }
561
+
562
+ return (
563
+ <div style={styles.listContainer} onContextMenu={(e) => { e.preventDefault(); onContextMenu(e, null); }}>
564
+ <table style={styles.listTable} data-bb-filebrowser-list>
565
+ <thead style={styles.listHeader}>
566
+ <tr>
567
+ <th
568
+ style={{ ...styles.listHeaderCell, ...(sort.field === 'name' ? styles.listHeaderCellActive : {}), minWidth: '200px' }}
569
+ onClick={() => onToggleSort('name')}
570
+ >
571
+ NAME{renderSortArrow('name')}
572
+ </th>
573
+ <th
574
+ style={{ ...styles.listHeaderCell, ...(sort.field === 'size' ? styles.listHeaderCellActive : {}), width: '80px', textAlign: 'right' }}
575
+ onClick={() => onToggleSort('size')}
576
+ >
577
+ SIZE{renderSortArrow('size')}
578
+ </th>
579
+ <th
580
+ style={{ ...styles.listHeaderCell, ...(sort.field === 'modifiedAt' ? styles.listHeaderCellActive : {}), width: '120px' }}
581
+ onClick={() => onToggleSort('modifiedAt')}
582
+ >
583
+ MODIFIED{renderSortArrow('modifiedAt')}
584
+ </th>
585
+ <th
586
+ style={{ ...styles.listHeaderCell, ...(sort.field === 'type' ? styles.listHeaderCellActive : {}), width: '80px' }}
587
+ onClick={() => onToggleSort('type')}
588
+ >
589
+ TYPE{renderSortArrow('type')}
590
+ </th>
591
+ </tr>
592
+ </thead>
593
+ <tbody>
594
+ {files.map((file, i) => {
595
+ const isSelected = selectedIds.has(file.id);
596
+ const isFocused = i === focusedIndex;
597
+
598
+ return (
599
+ <tr
600
+ key={file.id}
601
+ style={{
602
+ ...styles.listRow,
603
+ ...(isSelected ? styles.listRowSelected : {}),
604
+ ...(isFocused && !isSelected ? { outline: '1px solid var(--glia-color-accent, #d4a84b)', outlineOffset: '-1px' } : {}),
605
+ }}
606
+ onClick={(e) => onSelect(file.id, e.metaKey || e.ctrlKey)}
607
+ onDoubleClick={() => onActivate(file)}
608
+ onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onSelect(file.id, false); onContextMenu(e, file); }}
609
+ data-bb-filebrowser-item
610
+ data-id={file.id}
611
+ data-selected={isSelected || undefined}
612
+ >
613
+ <td style={{ ...styles.listCell, ...styles.listCellName }}>
614
+ <span style={styles.listCellIcon}>{getFileIcon(file)}</span>
615
+ <span>{file.name}</span>
616
+ </td>
617
+ <td style={{ ...styles.listCell, ...styles.listCellSize }}>
618
+ {file.type === 'folder' ? '\u2014' : formatFileSize(file.size)}
619
+ </td>
620
+ <td style={{ ...styles.listCell, ...styles.listCellDate }}>
621
+ {formatDate(file.modifiedAt)}
622
+ </td>
623
+ <td style={{ ...styles.listCell, ...styles.listCellType }}>
624
+ {file.type}
625
+ </td>
626
+ </tr>
627
+ );
628
+ })}
629
+ </tbody>
630
+ </table>
631
+ </div>
632
+ );
633
+ }
634
+
635
+ interface StatusBarProps {
636
+ totalItems: number;
637
+ selectedCount: number;
638
+ }
639
+
640
+ function StatusBar({ totalItems, selectedCount }: StatusBarProps) {
641
+ return (
642
+ <div style={styles.statusBar} data-bb-filebrowser-statusbar>
643
+ <span>{totalItems} ITEM{totalItems !== 1 ? 'S' : ''}</span>
644
+ {selectedCount > 0 && (
645
+ <span>{selectedCount} SELECTED</span>
646
+ )}
647
+ </div>
648
+ );
649
+ }
650
+
651
+ // ═══════════════════════════════════════════════════════════════════════════
652
+ // Main Component
653
+ // ═══════════════════════════════════════════════════════════════════════════
654
+
655
+ /**
656
+ * File browser component with grid/list views, toolbar, and keyboard navigation.
657
+ *
658
+ * @example
659
+ * ```tsx
660
+ * <FileBrowser
661
+ * files={myFiles}
662
+ * onFileActivate={(file) => console.log('Activated:', file.name)}
663
+ * defaultViewMode="grid"
664
+ * />
665
+ * ```
666
+ */
667
+ export function FileBrowser({
668
+ files,
669
+ initialPath,
670
+ onFileActivate,
671
+ onSelectionChange,
672
+ getContextMenuItems,
673
+ defaultViewMode = 'grid',
674
+ defaultSort,
675
+ showToolbar = true,
676
+ showBreadcrumb = true,
677
+ showStatusBar = true,
678
+ className,
679
+ style,
680
+ }: FileBrowserProps) {
681
+ const containerRef = useRef<HTMLDivElement>(null);
682
+ const [focusedIndex, setFocusedIndex] = useState(-1);
683
+
684
+ // Context menu state (local, not using global ContextMenu)
685
+ const [contextMenu, setContextMenu] = useState<{
686
+ isOpen: boolean;
687
+ position: { x: number; y: number };
688
+ items: ContextMenuItem[];
689
+ }>({ isOpen: false, position: { x: 0, y: 0 }, items: [] });
690
+
691
+ const browser = useFileBrowser(files, initialPath ?? null);
692
+
693
+ // Initialize defaults
694
+ useEffect(() => {
695
+ if (defaultViewMode) browser.setViewMode(defaultViewMode);
696
+ if (defaultSort) browser.setSort(defaultSort);
697
+ // eslint-disable-next-line react-hooks/exhaustive-deps
698
+ }, []);
699
+
700
+ // Initialize starting path
701
+ useEffect(() => {
702
+ if (initialPath) browser.navigateTo(initialPath);
703
+ // eslint-disable-next-line react-hooks/exhaustive-deps
704
+ }, []);
705
+
706
+ // Notify selection changes
707
+ useEffect(() => {
708
+ onSelectionChange?.(Array.from(browser.selectedIds));
709
+ }, [browser.selectedIds, onSelectionChange]);
710
+
711
+ // Reset focused index when folder changes
712
+ useEffect(() => {
713
+ setFocusedIndex(-1);
714
+ }, [browser.currentFolderId]);
715
+
716
+ // Handle file activation
717
+ const handleActivate = useCallback(
718
+ (file: FileItem) => {
719
+ if (file.type === 'folder') {
720
+ browser.navigateTo(file.id);
721
+ } else {
722
+ onFileActivate?.(file);
723
+ }
724
+ },
725
+ [browser.navigateTo, onFileActivate]
726
+ );
727
+
728
+ // Handle context menu
729
+ const handleContextMenu = useCallback(
730
+ (e: React.MouseEvent, file: FileItem | null) => {
731
+ if (!getContextMenuItems) return;
732
+ const items = getContextMenuItems(file);
733
+ if (items.length === 0) return;
734
+ setContextMenu({
735
+ isOpen: true,
736
+ position: { x: e.clientX, y: e.clientY },
737
+ items,
738
+ });
739
+ },
740
+ [getContextMenuItems]
741
+ );
742
+
743
+ const closeContextMenu = useCallback(() => {
744
+ setContextMenu((prev) => ({ ...prev, isOpen: false }));
745
+ }, []);
746
+
747
+ // Compute grid columns for 2D arrow-key navigation
748
+ const gridColumns = useMemo(() => {
749
+ if (browser.viewMode !== 'grid') return 1;
750
+ const containerWidth = containerRef.current?.clientWidth ?? 800;
751
+ const itemWidth = 88 + 8; // grid item width + gap
752
+ return Math.max(1, Math.floor((containerWidth - 24) / itemWidth));
753
+ }, [browser.viewMode, browser.visibleFiles.length]);
754
+
755
+ // Keyboard navigation
756
+ useEffect(() => {
757
+ const el = containerRef.current;
758
+ if (!el) return;
759
+
760
+ const handleKeyDown = (e: KeyboardEvent) => {
761
+ const fileCount = browser.visibleFiles.length;
762
+ if (fileCount === 0) return;
763
+
764
+ switch (e.key) {
765
+ case 'ArrowDown': {
766
+ e.preventDefault();
767
+ const step = browser.viewMode === 'grid' ? gridColumns : 1;
768
+ setFocusedIndex((prev) => Math.min(prev + step, fileCount - 1));
769
+ break;
770
+ }
771
+ case 'ArrowUp': {
772
+ e.preventDefault();
773
+ const step = browser.viewMode === 'grid' ? gridColumns : 1;
774
+ setFocusedIndex((prev) => Math.max(prev - step, 0));
775
+ break;
776
+ }
777
+ case 'ArrowRight': {
778
+ if (browser.viewMode === 'grid') {
779
+ e.preventDefault();
780
+ setFocusedIndex((prev) => Math.min(prev + 1, fileCount - 1));
781
+ }
782
+ break;
783
+ }
784
+ case 'ArrowLeft': {
785
+ if (browser.viewMode === 'grid') {
786
+ e.preventDefault();
787
+ setFocusedIndex((prev) => Math.max(prev - 1, 0));
788
+ }
789
+ break;
790
+ }
791
+ case 'Enter': {
792
+ e.preventDefault();
793
+ if (focusedIndex >= 0 && focusedIndex < fileCount) {
794
+ const file = browser.visibleFiles[focusedIndex];
795
+ browser.select(file.id, false);
796
+ handleActivate(file);
797
+ }
798
+ break;
799
+ }
800
+ case 'Backspace': {
801
+ e.preventDefault();
802
+ browser.navigateUp();
803
+ break;
804
+ }
805
+ case 'Escape': {
806
+ e.preventDefault();
807
+ browser.clearSelection();
808
+ setFocusedIndex(-1);
809
+ if (contextMenu.isOpen) closeContextMenu();
810
+ break;
811
+ }
812
+ case 'a':
813
+ case 'A': {
814
+ if (e.metaKey || e.ctrlKey) {
815
+ e.preventDefault();
816
+ browser.selectAll();
817
+ }
818
+ break;
819
+ }
820
+ }
821
+ };
822
+
823
+ el.addEventListener('keydown', handleKeyDown);
824
+ return () => el.removeEventListener('keydown', handleKeyDown);
825
+ }, [browser, focusedIndex, gridColumns, handleActivate, contextMenu.isOpen, closeContextMenu]);
826
+
827
+ // Auto-select on focus change via keyboard
828
+ useEffect(() => {
829
+ if (focusedIndex >= 0 && focusedIndex < browser.visibleFiles.length) {
830
+ const file = browser.visibleFiles[focusedIndex];
831
+ browser.select(file.id, false);
832
+ }
833
+ }, [focusedIndex]); // eslint-disable-line react-hooks/exhaustive-deps
834
+
835
+ return (
836
+ <div
837
+ ref={containerRef}
838
+ className={className}
839
+ style={{ ...styles.container, ...style }}
840
+ tabIndex={0}
841
+ data-bb-filebrowser
842
+ >
843
+ {showToolbar && (
844
+ <Toolbar
845
+ canGoBack={browser.canGoBack}
846
+ canGoForward={browser.canGoForward}
847
+ canGoUp={browser.canGoUp}
848
+ viewMode={browser.viewMode}
849
+ searchQuery={browser.searchQuery}
850
+ onBack={browser.navigateBack}
851
+ onForward={browser.navigateForward}
852
+ onUp={browser.navigateUp}
853
+ onViewMode={browser.setViewMode}
854
+ onSearchChange={browser.setSearchQuery}
855
+ />
856
+ )}
857
+
858
+ {showBreadcrumb && (
859
+ <Breadcrumb
860
+ breadcrumb={browser.breadcrumb}
861
+ onNavigate={browser.navigateTo}
862
+ />
863
+ )}
864
+
865
+ {browser.viewMode === 'grid' ? (
866
+ <GridView
867
+ files={browser.visibleFiles}
868
+ selectedIds={browser.selectedIds}
869
+ focusedIndex={focusedIndex}
870
+ onSelect={browser.select}
871
+ onActivate={handleActivate}
872
+ onContextMenu={handleContextMenu}
873
+ />
874
+ ) : (
875
+ <ListView
876
+ files={browser.visibleFiles}
877
+ selectedIds={browser.selectedIds}
878
+ focusedIndex={focusedIndex}
879
+ sort={browser.sort}
880
+ onSelect={browser.select}
881
+ onActivate={handleActivate}
882
+ onToggleSort={browser.toggleSortField}
883
+ onContextMenu={handleContextMenu}
884
+ />
885
+ )}
886
+
887
+ {showStatusBar && (
888
+ <StatusBar
889
+ totalItems={browser.totalItems}
890
+ selectedCount={browser.selectedCount}
891
+ />
892
+ )}
893
+
894
+ {/* Inline context menu */}
895
+ {contextMenu.isOpen && (
896
+ <>
897
+ <div
898
+ style={{ position: 'fixed', inset: 0, zIndex: 999998 }}
899
+ onClick={closeContextMenu}
900
+ onContextMenu={(e) => { e.preventDefault(); closeContextMenu(); }}
901
+ />
902
+ <div
903
+ style={{
904
+ position: 'fixed',
905
+ left: contextMenu.position.x,
906
+ top: contextMenu.position.y,
907
+ minWidth: '180px',
908
+ background: 'var(--glia-color-bg-elevated, #111111)',
909
+ border: '1px solid var(--glia-color-border, #333333)',
910
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 1px rgba(212, 168, 75, 0.15)',
911
+ padding: '6px',
912
+ zIndex: 999999,
913
+ borderRadius: 'var(--glia-radius-md, 3px)',
914
+ }}
915
+ >
916
+ {contextMenu.items.map((item, idx) => {
917
+ if (item.separator) {
918
+ return (
919
+ <div
920
+ key={item.id || `sep-${idx}`}
921
+ style={{
922
+ height: '1px',
923
+ margin: '5px 8px',
924
+ background: 'var(--glia-color-border, #333333)',
925
+ }}
926
+ />
927
+ );
928
+ }
929
+ return (
930
+ <button
931
+ key={item.id}
932
+ type="button"
933
+ style={{
934
+ width: '100%',
935
+ textAlign: 'left',
936
+ padding: '8px 12px',
937
+ background: 'transparent',
938
+ border: 0,
939
+ fontFamily: 'var(--glia-font-mono)',
940
+ fontSize: '11px',
941
+ letterSpacing: '0.1em',
942
+ textTransform: 'uppercase',
943
+ cursor: 'default',
944
+ borderRadius: '3px',
945
+ display: 'flex',
946
+ alignItems: 'center',
947
+ gap: '10px',
948
+ color: item.danger
949
+ ? 'var(--glia-color-accent-destructive, #c44444)'
950
+ : 'var(--glia-color-text-muted, #cccccc)',
951
+ opacity: item.disabled ? 0.4 : 1,
952
+ pointerEvents: item.disabled ? 'none' : 'auto',
953
+ }}
954
+ disabled={item.disabled}
955
+ onClick={() => {
956
+ item.action?.();
957
+ closeContextMenu();
958
+ }}
959
+ onMouseEnter={(e) => {
960
+ (e.currentTarget as HTMLButtonElement).style.background = item.danger
961
+ ? 'rgba(196, 92, 92, 0.12)'
962
+ : 'var(--glia-glass-hover-bg, rgba(212, 168, 75, 0.10))';
963
+ }}
964
+ onMouseLeave={(e) => {
965
+ (e.currentTarget as HTMLButtonElement).style.background = 'transparent';
966
+ }}
967
+ >
968
+ {item.icon && <span style={{ fontSize: '14px', width: '18px', textAlign: 'center' }}>{item.icon}</span>}
969
+ {item.label}
970
+ {item.shortcut && (
971
+ <span style={{ marginLeft: 'auto', fontSize: '10px', opacity: 0.6 }}>{item.shortcut}</span>
972
+ )}
973
+ </button>
974
+ );
975
+ })}
976
+ </div>
977
+ </>
978
+ )}
979
+ </div>
980
+ );
981
+ }