anentrypoint-design 0.0.385 → 0.0.387

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 (71) hide show
  1. package/dist/247420.css +33 -0
  2. package/dist/247420.js +53 -51
  3. package/package.json +1 -1
  4. package/src/components/agent-chat/controls.js +143 -0
  5. package/src/components/agent-chat/empty-state.js +63 -0
  6. package/src/components/agent-chat/message-rows.js +141 -0
  7. package/src/components/agent-chat/surface.js +206 -0
  8. package/src/components/agent-chat/thread-behaviour.js +50 -0
  9. package/src/components/agent-chat.js +6 -546
  10. package/src/components/chat/composer-affordances.js +109 -0
  11. package/src/components/chat/composer.js +256 -0
  12. package/src/components/chat/threads.js +105 -0
  13. package/src/components/chat-message-parts/agent-nodes.js +103 -0
  14. package/src/components/chat-message-parts/inline.js +106 -0
  15. package/src/components/chat-message-parts/prose-nodes.js +133 -0
  16. package/src/components/chat-message-parts/renderers.js +89 -0
  17. package/src/components/chat-message-parts.js +12 -408
  18. package/src/components/chat-minimap/minimap.js +167 -0
  19. package/src/components/chat-minimap/paint.js +73 -0
  20. package/src/components/chat-minimap/preview.js +41 -0
  21. package/src/components/chat-minimap.js +7 -263
  22. package/src/components/chat.js +20 -628
  23. package/src/components/community/chrome.js +63 -0
  24. package/src/components/community/navigation.js +167 -0
  25. package/src/components/community/presence.js +106 -0
  26. package/src/components/community/shell.js +17 -0
  27. package/src/components/community/views.js +131 -0
  28. package/src/components/community.js +18 -447
  29. package/src/components/files/chrome.js +140 -0
  30. package/src/components/files/entries.js +156 -0
  31. package/src/components/files/grid-controls.js +63 -0
  32. package/src/components/files/grid.js +177 -0
  33. package/src/components/files/types.js +69 -0
  34. package/src/components/files-modals/dialogs.js +118 -0
  35. package/src/components/files-modals/modal-shell.js +153 -0
  36. package/src/components/files-modals/preview-bodies.js +129 -0
  37. package/src/components/files-modals/preview-containers.js +96 -0
  38. package/src/components/files-modals.js +14 -475
  39. package/src/components/files.js +15 -564
  40. package/src/components/freddie/pages-config.js +3 -94
  41. package/src/components/freddie/pages-models.js +97 -0
  42. package/src/components/freddie.js +2 -1
  43. package/src/components/interaction-primitives/mobile.js +25 -0
  44. package/src/components/interaction-primitives/pointer.js +214 -0
  45. package/src/components/interaction-primitives/reorderable.js +47 -0
  46. package/src/components/interaction-primitives/shortcuts.js +119 -0
  47. package/src/components/interaction-primitives.js +16 -388
  48. package/src/components/sessions/conversation-list.js +230 -0
  49. package/src/components/sessions/dashboard.js +202 -0
  50. package/src/components/sessions/detail-bits.js +49 -0
  51. package/src/components/sessions/format.js +42 -0
  52. package/src/components/sessions/session-card.js +92 -0
  53. package/src/components/sessions.js +16 -579
  54. package/src/components/voice/audio-cue.js +39 -0
  55. package/src/components/voice/capture.js +90 -0
  56. package/src/components/voice/playback.js +75 -0
  57. package/src/components/voice/settings-modal.js +104 -0
  58. package/src/components/voice.js +16 -293
  59. package/src/css/app-shell/base.css +23 -0
  60. package/src/css/app-shell/states-interactions.css +10 -0
  61. package/src/kits/os/freddie/chat-protocol.js +32 -0
  62. package/src/kits/os/freddie/chat-transport.js +178 -0
  63. package/src/kits/os/freddie/pages-chat.js +10 -180
  64. package/src/kits/os/shell-chrome.js +163 -0
  65. package/src/kits/os/shell-geometry.js +59 -0
  66. package/src/kits/os/shell.js +178 -335
  67. package/src/page-html/client-script.js +151 -0
  68. package/src/page-html/head-tags.js +59 -0
  69. package/src/page-html/markdown.js +68 -0
  70. package/src/page-html/page-styles.js +44 -0
  71. package/src/page-html.js +14 -291
@@ -1,566 +1,17 @@
1
1
  // File primitives — matches upstream signatures.
2
-
3
- import * as webjsx from '../../vendor/webjsx/index.js';
4
- import { Btn, Icon } from './shell.js';
5
- const h = webjsx.createElement;
6
-
7
- const FILE_TYPES = ['dir', 'image', 'video', 'audio', 'code', 'text', 'archive', 'document', 'symlink', 'other'];
8
- const TYPE_ICON = {
9
- dir: 'folder', image: 'file-image', video: 'file-video', audio: 'file-audio', code: 'file-code',
10
- text: 'file-text', archive: 'file-zip', document: 'file-text', symlink: 'link', other: 'file'
11
- };
12
-
13
- const TYPE_LABELS = {
14
- dir: 'folder',
15
- image: 'image file',
16
- video: 'video file',
17
- audio: 'audio file',
18
- code: 'code file',
19
- text: 'text file',
20
- archive: 'archive file',
21
- document: 'document file',
22
- symlink: 'symbolic link',
23
- other: 'file'
2
+ //
3
+ // This module is a barrel: every component lives in a single-responsibility
4
+ // submodule under ./files/, and the public export surface here is unchanged
5
+ // no consumer import needs to move.
6
+
7
+ import { fileGlyph, fmtFileSize, FileIcon, sortFiles } from './files/types.js';
8
+ import { FileRow, FileSkeleton } from './files/entries.js';
9
+ import { FileGrid } from './files/grid.js';
10
+ import { FileToolbar, RootsPicker, DropZone, UploadProgress, EmptyState, BreadcrumbPath, BulkBar } from './files/chrome.js';
11
+
12
+ export {
13
+ fileGlyph, fmtFileSize, FileIcon, sortFiles,
14
+ FileRow, FileSkeleton,
15
+ FileGrid,
16
+ FileToolbar, RootsPicker, DropZone, UploadProgress, EmptyState, BreadcrumbPath, BulkBar,
24
17
  };
25
-
26
- export function fileGlyph(type) {
27
- return TYPE_ICON[type] || TYPE_ICON.other;
28
- }
29
-
30
- // The canonical kit byte formatter (chat.js re-exports it as fmtBytes). One
31
- // format everywhere: '0 B' for zero; the em-dash means unknown/null/invalid
32
- // (NaN, a negative count, or anything non-numeric never reaches the divide
33
- // loop — previously NaN fell through to the loop unchanged and rendered the
34
- // literal string "NaN B").
35
- export function fmtFileSize(bytes) {
36
- if (bytes == null) return '—';
37
- if (typeof bytes !== 'number' || Number.isNaN(bytes) || bytes < 0) return '—';
38
- if (bytes === 0) return '0 B';
39
- const u = ['B', 'KB', 'MB', 'GB', 'TB'];
40
- let i = 0, n = bytes;
41
- while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
42
- return n.toFixed(i === 0 ? 0 : 1) + ' ' + u[i];
43
- }
44
-
45
- export function FileIcon({ type = 'other' } = {}) {
46
- return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, Icon(fileGlyph(type)));
47
- }
48
-
49
- // Default action set for FileRow. A host without mutation endpoints passes a
50
- // narrower `actions` list (e.g. ['download']) so the row renders no dead controls.
51
- const FILE_ROW_ACTIONS = ['download', 'rename', 'move', 'delete'];
52
-
53
- export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked,
54
- actions = FILE_ROW_ACTIONS, busy = false, selectable = false, selected = false, onToggleSelect } = {}) {
55
- // permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
56
- // be listed (the dir stat saw it) but not opened — show an ASCII tag and
57
- // disable the open button so the row reads honestly instead of silently
58
- // failing on click.
59
- const noAccess = locked || permissions === 'EACCES' || (Array.isArray(permissions) && permissions.length === 0);
60
- const readOnly = !noAccess && Array.isArray(permissions) && permissions.indexOf('write') === -1 && permissions.indexOf('read') !== -1;
61
- const permTag = noAccess ? 'no access' : (readOnly ? 'read-only' : null);
62
- // permTag is rendered as its own chip (a SHAPE channel, not folded into the
63
- // muted meta text) - so drop it from the meta join, but keep it in the
64
- // accessible label so AT still announces the restriction.
65
- const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null].filter(Boolean).join(' · ');
66
- const typeLabel = TYPE_LABELS[type] || 'file';
67
- const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}${permTag ? ', ' + permTag : ''}`;
68
- const canOpen = onOpen && !noAccess && !busy;
69
- // Mutation actions on a read-only/no-access row render disabled (with a
70
- // 'read-only' title) instead of vanishing, so the affordance reads honestly.
71
- // `busy` (in-flight mutation) disables every control on the row.
72
- const mutateDisabled = busy || readOnly || noAccess;
73
- const actBtn = (act, title, ariaLabel, icon, warn) => h('button', {
74
- key: 'act-' + act,
75
- type: 'button',
76
- class: 'ds-file-act' + (warn ? ' ds-file-act-warn' : ''),
77
- title: mutateDisabled && act !== 'download' ? 'read-only' : title,
78
- 'aria-label': ariaLabel,
79
- disabled: (act === 'download' ? busy : mutateDisabled) ? true : null,
80
- 'aria-disabled': (act === 'download' ? busy : mutateDisabled) ? 'true' : null,
81
- onclick: () => onAction(act),
82
- }, Icon(icon));
83
- const actionBtns = onAction ? [
84
- actions.indexOf('download') !== -1 && type !== 'dir'
85
- ? actBtn('download', 'download', `download ${name}`, 'arrow-down', false) : null,
86
- actions.indexOf('rename') !== -1
87
- ? actBtn('rename', 'rename', `rename ${name}`, 'pencil', false) : null,
88
- // Single-file move used to require checkbox-select + BulkBar - a
89
- // per-row affordance matches fsbrowse and the kit rename/delete rows
90
- // already on this row (no reason move alone needed a select detour).
91
- actions.indexOf('move') !== -1
92
- ? actBtn('move', 'move', `move ${name}`, 'arrow-right', false) : null,
93
- actions.indexOf('delete') !== -1
94
- ? actBtn('delete', 'delete', `delete ${name}`, 'x', true) : null,
95
- ].filter(Boolean) : [];
96
- // Multi-select checkbox — a sibling control before the open button so the
97
- // row stays valid HTML (no interactive nesting). A no-access entry cannot
98
- // be marked (bulk mutations would fail on it anyway).
99
- const checkCtl = selectable ? h('button', {
100
- key: 'mark',
101
- type: 'button',
102
- class: 'ds-file-check' + (selected ? ' is-marked' : ''),
103
- role: 'checkbox',
104
- 'aria-checked': selected ? 'true' : 'false',
105
- 'aria-label': (selected ? 'unselect ' : 'select ') + name,
106
- disabled: (noAccess || busy) ? true : null,
107
- onclick: onToggleSelect ? (e) => onToggleSelect({ range: !!e.shiftKey }) : null,
108
- }, h('span', { class: 'ds-check-box', 'aria-hidden': 'true' })) : null;
109
- // A role=button row containing real <button> action controls is invalid
110
- // HTML (interactive nesting). Instead the row is a plain container and the
111
- // primary "open" affordance is itself a real <button> (native keyboard +
112
- // semantics); the per-file action buttons sit alongside it as siblings.
113
- const rowKids = [
114
- checkCtl,
115
- h('button', {
116
- key: 'open',
117
- type: 'button',
118
- class: 'ds-file-open',
119
- onclick: canOpen ? onOpen : null,
120
- 'aria-label': accessibleLabel + (noAccess ? ' (no access)' : ''),
121
- 'aria-pressed': active ? 'true' : 'false',
122
- disabled: canOpen ? null : true,
123
- },
124
- ...[
125
- code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
126
- FileIcon({ type }),
127
- h('span', { class: 'title' }, name),
128
- h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—'),
129
- permTag ? h('span', { class: 'ds-file-perm-tag' + (noAccess ? ' is-noaccess' : ''), 'aria-hidden': 'true' }, permTag) : null,
130
- ].filter(Boolean)
131
- ),
132
- actionBtns.length ? h('span', { key: 'acts', class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
133
- ...actionBtns
134
- ) : null,
135
- ].filter(Boolean);
136
- return h('div', {
137
- key,
138
- class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : '')
139
- + (readOnly ? ' is-restricted' : '')
140
- + (selected ? ' is-marked' : '') + (selectable ? ' is-selectable' : ''),
141
- 'data-file-type': type,
142
- 'aria-busy': busy ? 'true' : null,
143
- }, ...rowKids);
144
- }
145
-
146
- // FileSkeleton — placeholder shimmer rows shown while a directory loads, so the
147
- // grid does not flash from a bare spinner to a full list (predictable perceived
148
- // perf, the file-manager feel). `rows` controls how many ghost rows render.
149
- export function FileSkeleton({ rows = 12 } = {}) {
150
- return h('div', { class: 'ds-file-grid ds-file-skeleton', role: 'status', 'aria-busy': 'true', 'aria-label': 'loading files' },
151
- ...Array.from({ length: Math.max(1, rows) }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-file-row ds-file-row-skeleton', 'aria-hidden': 'true' },
152
- h('span', { class: 'ds-skel ds-skel-icon' }),
153
- h('span', { class: 'ds-skel ds-skel-title' }),
154
- h('span', { class: 'ds-skel ds-skel-meta' })))
155
- );
156
- }
157
-
158
- // Sort a file list by a key (name/size/modified/type), dirs-first always so the
159
- // hierarchy reads top-down regardless of sort. `dir` is 'asc'|'desc'.
160
- // `modifiedTs` (epoch ms) is used for the modified sort when present, since the
161
- // `modified` field is a pre-formatted relative string the host passes for display.
162
- export function sortFiles(files = [], sort = 'name', dir = 'asc') {
163
- const mul = dir === 'desc' ? -1 : 1;
164
- const cmp = (a, b) => {
165
- // Directories always cluster before files; within a cluster, apply the sort.
166
- const ad = a.type === 'dir' ? 0 : 1, bd = b.type === 'dir' ? 0 : 1;
167
- if (ad !== bd) return ad - bd;
168
- let r = 0;
169
- if (sort === 'size') r = (a.size || 0) - (b.size || 0);
170
- else if (sort === 'modified') r = (a.modifiedTs || 0) - (b.modifiedTs || 0);
171
- else if (sort === 'type') r = String(a.type || '').localeCompare(String(b.type || ''));
172
- else r = String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' });
173
- return r * mul || String(a.name || '').localeCompare(String(b.name || ''));
174
- };
175
- return files.slice().sort(cmp);
176
- }
177
-
178
- // FileGrid — the directory listing. Optional in-grid sort + filter make it a
179
- // real file manager rather than a static dump:
180
- // sort : { key, dir, onSort(key) } - clickable column headers (name/size/modified)
181
- // filter : { value, onInput, placeholder } - a quick in-dir name filter
182
- // onOpen(f) opens a row; onAction(act,f) wires the per-row download/rename/delete.
183
- // Keyboard nav: the grid is a focusable listbox - ArrowUp/Down move the active
184
- // row, Enter opens it, Backspace asks the host to go up (onUp). The host keeps no
185
- // focus state; the grid tracks it on the DOM via roving tabindex.
186
- // How many rows to render before the "show more" cap kicks in. A node_modules-
187
- // scale directory would otherwise flood the DOM with thousands of rows (and make
188
- // the roving-tabindex querySelectorAll scan O(n) per keypress). Render the first
189
- // CAP and a "show N more" row, mirroring the History tab's "load N older".
190
- const FILE_GRID_CAP = 200;
191
-
192
- export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet', emptyAction,
193
- sort, filter, loading = false,
194
- shown, onShowMore, actions, busy,
195
- // Canonical multi-select contract (shared with
196
- // SessionDashboard): selected/onToggleSelect.
197
- // marked/onMark are accepted FileGrid aliases.
198
- selectable = false, selected, onToggleSelect,
199
- marked = selected, onMark = onToggleSelect,
200
- onSelectAll, onClearSelection,
201
- density = 'list', onDensity, thumbUrl } = {}) {
202
- // Skeleton ONLY for a cold load. A refresh of a populated grid (rename /
203
- // delete / upload round-trip) keeps the rows on screen and dims them -
204
- // flashing the whole directory to shimmer rows on every mutation reads as
205
- // data loss.
206
- if (loading && !files.length) return FileSkeleton({ rows: 12 });
207
- // A filtered miss is NOT an empty directory: when the in-grid filter narrows
208
- // to zero matches, the host still passes an empty `files` array - but we must
209
- // keep the controls toolbar (the filter input that caused the miss) mounted so
210
- // the user can clear/edit it to recover. Only a genuinely-empty directory (no
211
- // active filter) gets the bare cold EmptyState early-return.
212
- const hasFilter = !!(filter && (filter.value || '').length > 0);
213
- if (!files.length && !hasFilter) return EmptyState({ text: emptyText, glyph: Icon('folder-open', { size: 28 }), action: emptyAction });
214
- const refreshing = loading && files.length > 0;
215
- // Cap the rendered rows. `shown` (host-controlled) overrides the default cap
216
- // so "show more" can grow it; otherwise default to FILE_GRID_CAP.
217
- const limit = shown != null ? shown : FILE_GRID_CAP;
218
- const capped = files.length > limit;
219
- const visible = capped ? files.slice(0, limit) : files;
220
- const isThumb = density === 'thumb';
221
- // NOTE: the old `columns`-driven data-columns card-mode was removed - it placed
222
- // flex list-rows into a 2-4 col grid (squashed rows, mis-sized actions) and was
223
- // a half-wired third layout never exposed by the density radiogroup (list/
224
- // compact/thumb). Thumb density is the canonical multi-column grid.
225
- const gridAttrs = {};
226
- // Multi-select bookkeeping. Entries are keyed by path (fallback name); a
227
- // locked/EACCES entry is never selectable — bulk mutations would fail on it.
228
- const entryKeyOf = (f) => f.path || f.name;
229
- const isLockedEntry = (f) => f.locked || f.permissions === 'EACCES'
230
- || (Array.isArray(f.permissions) && f.permissions.length === 0);
231
- const selSet = marked instanceof Set ? marked : new Set(marked || []);
232
- const selectableKeys = selectable ? visible.filter((f) => !isLockedEntry(f)).map(entryKeyOf) : [];
233
- // Keyboard: roving focus over the open buttons inside the grid (rows and
234
- // thumbnail cells share the pattern). Ctrl/Cmd+A selects all SHOWN rows.
235
- const onKeyDown = (e) => {
236
- const grid = e.currentTarget;
237
- const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled]), .ds-file-cell-open:not([disabled])'));
238
- const cur = opens.indexOf(document.activeElement);
239
- if (e.key === 'ArrowDown') { e.preventDefault(); opens[Math.min(opens.length - 1, cur + 1)]?.focus(); }
240
- else if (e.key === 'ArrowUp') { e.preventDefault(); (cur <= 0 ? opens[0] : opens[cur - 1])?.focus(); }
241
- else if (e.key === 'Home') { e.preventDefault(); opens[0]?.focus(); }
242
- else if (e.key === 'End') { e.preventDefault(); opens[opens.length - 1]?.focus(); }
243
- else if (e.key === 'Backspace') { e.preventDefault(); onUp && onUp(); }
244
- else if ((e.key === 'a' || e.key === 'A') && (e.ctrlKey || e.metaKey)
245
- && selectable && onSelectAll && selectableKeys.length) {
246
- e.preventDefault(); onSelectAll(selectableKeys);
247
- }
248
- };
249
- const head = sort ? FileSortHeader(sort) : null;
250
- // Tri-state select-all over the selectable SHOWN rows (the cap label below
251
- // already tells the user more rows exist beyond the window).
252
- const selOfVisible = selectableKeys.filter((k) => selSet.has(k)).length;
253
- const allState = selOfVisible === 0 ? 'false' : (selOfVisible === selectableKeys.length ? 'true' : 'mixed');
254
- const selectAllCtl = (selectable && onSelectAll && selectableKeys.length)
255
- ? h('button', { key: 'selall', type: 'button', class: 'ds-file-selectall', role: 'checkbox',
256
- 'aria-checked': allState,
257
- 'aria-label': allState === 'true' ? 'clear selection' : 'select all ' + selectableKeys.length + ' shown files',
258
- onclick: () => (allState === 'true' && onClearSelection) ? onClearSelection() : onSelectAll(selectableKeys) },
259
- h('span', { class: 'ds-check-box', 'aria-hidden': 'true' }),
260
- h('span', {}, 'all'))
261
- : null;
262
- // Density picker — list / compact / thumbnails. A radiogroup, not tabs:
263
- // it switches presentation of the same content, not panels.
264
- const densityCtl = onDensity
265
- ? h('div', { key: 'density', class: 'ds-density', role: 'radiogroup', 'aria-label': 'view density' },
266
- ...DENSITIES.map(([k, label], idx) => h('button', {
267
- key: 'd-' + k, type: 'button', role: 'radio',
268
- class: 'ds-density-btn' + (density === k ? ' active' : ''),
269
- 'aria-checked': density === k ? 'true' : 'false',
270
- // Icon-led, but the density name stays the accessible name
271
- // (aria-label) + the native tooltip (title) so the control reads
272
- // dense without losing its label.
273
- 'aria-label': label, title: label,
274
- // Single tab stop: the checked radio is tabbable, the rest are
275
- // roved. Arrow/Home/End move + select (selection follows focus).
276
- tabindex: density === k ? '0' : '-1',
277
- onkeydown: (e) => rovingRadio(e, idx, DENSITIES, (tk) => { if (density !== tk) onDensity(tk); }),
278
- onclick: () => { if (density !== k) onDensity(k); },
279
- }, Icon(DENSITY_ICONS[k], { size: 15 }))))
280
- : null;
281
- // One toolbar baseline: filter + select-all + sort sit left, density is
282
- // pushed right by the spread. The filter used to be a separate right-aligned
283
- // strip ABOVE controls, giving two strips with conflicting alignment.
284
- const filterCtl = filter ? h('span', { key: 'filterwrap', class: 'ds-file-filter-wrap' },
285
- h('input', {
286
- key: 'filter',
287
- class: 'ds-file-filter-input', type: 'search',
288
- value: filter.value || '', placeholder: filter.placeholder || 'Filter files',
289
- 'aria-label': filter.placeholder || 'Filter files in this directory',
290
- oninput: (e) => filter.onInput && filter.onInput(e.target.value),
291
- onkeydown: (e) => {
292
- if (e.key === 'Escape' && filter.value) { e.preventDefault(); e.stopPropagation(); filter.onInput && filter.onInput(''); }
293
- },
294
- }),
295
- // Announces the filtered count as the filter narrows the list, so a
296
- // screen-reader user gets the same feedback a sighted user reads off
297
- // the grid without having to re-scan it after every keystroke. `files`
298
- // here is already the host's filter-applied set (see hasFilter above) -
299
- // there is no separate pre-filter total available inside this component.
300
- h('span', { key: 'filtercount', class: 'sr-only', role: 'status', 'aria-live': 'polite' },
301
- hasFilter ? files.length + (files.length === 1 ? ' file' : ' files') + ' shown' : '')
302
- ) : null;
303
- const leftKids = [filterCtl, selectAllCtl, head].filter(Boolean);
304
- const controlsKids = [
305
- ...leftKids,
306
- (leftKids.length && densityCtl) ? h('span', { key: 'spread', class: 'spread' }) : null,
307
- densityCtl].filter(Boolean);
308
- const controls = controlsKids.length
309
- ? h('div', { class: 'ds-file-controls' }, ...controlsKids)
310
- : null;
311
- // A filtered miss (zero rows but an active filter) renders the EmptyState
312
- // INSIDE the listing, below the controls toolbar, so the filter input stays
313
- // mounted and editable - the user can clear/edit it to recover instead of
314
- // being stranded with no toolbar (the early-return only fires for a genuinely
315
- // empty directory). The host passes filter-aware copy via emptyText.
316
- const filteredEmpty = !files.length && hasFilter;
317
- // role=group not listbox: the rows contain real <button> action controls, so
318
- // listbox/option semantics are invalid (an option can't host interactive
319
- // children). Keyboard nav still works via roving focus over the open buttons.
320
- const grid = filteredEmpty ? EmptyState({ text: emptyText, glyph: Icon('folder-open', { size: 28 }) }) : h('div', {
321
- class: 'ds-file-grid' + (isThumb ? ' ds-file-grid-thumb' : '') + (refreshing ? ' is-refreshing' : ''),
322
- role: 'group', 'aria-label': 'files', tabindex: '0',
323
- 'aria-busy': refreshing ? 'true' : 'false',
324
- // Always concrete (webjsx's attribute diff can leave a null-valued
325
- // attribute unset when toggling away from the default).
326
- 'data-density': density || 'list',
327
- onkeydown: onKeyDown, ...gridAttrs },
328
- ...visible.map((f, i) => isThumb
329
- ? FileCell({
330
- key: f.path || f.name + i, f,
331
- selectable, selected: selSet.has(entryKeyOf(f)),
332
- onToggleSelect: onMark ? (opts) => onMark(f, opts) : null,
333
- onOpen,
334
- thumb: (thumbUrl && f.type === 'image') ? thumbUrl(f) : null,
335
- })
336
- : FileRow({
337
- key: f.path || f.name + i,
338
- name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
339
- permissions: f.permissions, locked: f.locked,
340
- actions: actions != null ? actions : undefined,
341
- busy: busy != null ? !!busy : !!f.busy,
342
- selectable, selected: selSet.has(entryKeyOf(f)),
343
- onToggleSelect: onMark ? (opts) => onMark(f, opts) : null,
344
- onOpen: onOpen ? () => onOpen(f) : null,
345
- onAction: onAction ? (act) => onAction(act, f) : null
346
- }))
347
- );
348
- // A count + "show more" affordance so a capped large dir reads as "more
349
- // exist", not "this is everything". aria-live announces the shown/total.
350
- const more = capped
351
- ? h('div', { class: 'ds-file-more' },
352
- h('span', { class: 'ds-file-more-count', role: 'status', 'aria-live': 'polite' },
353
- 'showing ' + visible.length + ' of ' + files.length),
354
- onShowMore ? h('button', { type: 'button', class: 'ds-file-more-btn',
355
- onclick: () => onShowMore(Math.min(files.length, limit + FILE_GRID_CAP)) },
356
- 'show ' + Math.min(FILE_GRID_CAP, files.length - limit) + ' more') : null)
357
- : null;
358
- return (controls || more)
359
- ? h('div', { class: 'ds-file-listing' }, controls, grid, more)
360
- : grid;
361
- }
362
-
363
- const DENSITIES = [['list', 'list'], ['compact', 'compact'], ['thumb', 'thumbnails']];
364
- const DENSITY_ICONS = { list: 'rows', compact: 'rows-tight', thumb: 'grid' };
365
-
366
- // Roving-radiogroup keyboard helper (the WAI-ARIA radio pattern): a radiogroup
367
- // is a SINGLE tab stop where Arrow/Home/End move AND select among options, with
368
- // selection following focus. `items` is the ordered [[key, ...], ...] list;
369
- // `onSelect(targetKey)` is the same handler the onclick fires. Mouse path is
370
- // unchanged - this only adds keyboard navigation.
371
- function rovingRadio(e, idx, items, onSelect) {
372
- let target = -1;
373
- if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') target = (idx - 1 + items.length) % items.length;
374
- else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') target = (idx + 1) % items.length;
375
- else if (e.key === 'Home') target = 0;
376
- else if (e.key === 'End') target = items.length - 1;
377
- else return;
378
- e.preventDefault();
379
- onSelect(items[target][0]);
380
- const sib = e.currentTarget.parentNode && e.currentTarget.parentNode.children[target];
381
- sib && sib.focus();
382
- }
383
-
384
- // FileCell — the thumbnail-density tile. Image entries show a real (lazy)
385
- // thumbnail through the host's confined thumbUrl; everything else keeps its
386
- // type icon. Same open/mark semantics as FileRow, same no-nesting rule.
387
- function FileCell({ key, f = {}, selectable = false, selected = false, onToggleSelect, onOpen, thumb } = {}) {
388
- const noAccess = f.locked || f.permissions === 'EACCES'
389
- || (Array.isArray(f.permissions) && f.permissions.length === 0);
390
- const canOpen = onOpen && !noAccess;
391
- const typeLabel = TYPE_LABELS[f.type] || 'file';
392
- const kids = [
393
- selectable ? h('button', {
394
- key: 'mark', type: 'button',
395
- class: 'ds-file-check ds-file-cell-check' + (selected ? ' is-marked' : ''),
396
- role: 'checkbox', 'aria-checked': selected ? 'true' : 'false',
397
- 'aria-label': (selected ? 'unselect ' : 'select ') + f.name,
398
- disabled: noAccess ? true : null,
399
- onclick: onToggleSelect ? (e) => onToggleSelect({ range: !!e.shiftKey }) : null,
400
- }, h('span', { class: 'ds-check-box', 'aria-hidden': 'true' })) : null,
401
- h('button', {
402
- key: 'open', type: 'button', class: 'ds-file-cell-open',
403
- onclick: canOpen ? () => onOpen(f) : null,
404
- disabled: canOpen ? null : true,
405
- 'aria-label': typeLabel + ': ' + f.name + (noAccess ? ' (no access)' : ''),
406
- },
407
- h('span', { class: 'ds-file-cell-media' },
408
- thumb
409
- ? h('img', { class: 'ds-file-cell-thumb', src: thumb, alt: '', loading: 'lazy' })
410
- : FileIcon({ type: f.type })),
411
- h('span', { class: 'ds-file-cell-name', title: f.name }, f.name),
412
- h('span', { class: 'ds-file-cell-meta' }, f.type === 'dir' ? 'folder' : fmtFileSize(f.size))),
413
- ].filter(Boolean);
414
- return h('div', {
415
- key,
416
- class: 'ds-file-cell' + (selected ? ' is-marked' : '') + (f.active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
417
- 'data-file-type': f.type,
418
- }, ...kids);
419
- }
420
-
421
- // BulkBar — the act-on-selection strip shown while a multi-select is active.
422
- // Host renders it above the grid; `actions` are [{ label, onClick, danger,
423
- // disabled }]; `busy` disables everything while a bulk operation is in flight.
424
- export function BulkBar({ count = 0, noun = 'file', nounPlural, actions = [], onClear, busy = false } = {}) {
425
- if (!count) return null;
426
- // 'entry' pluralizes to 'entries', not 'entrys' - handle the -y noun class
427
- // unless the host passes an explicit plural.
428
- const plural = nounPlural || (/[^aeiou]y$/.test(noun) ? noun.slice(0, -1) + 'ies' : noun + 's');
429
- const kids = [
430
- h('span', { key: 'count', class: 'ds-bulkbar-count', role: 'status', 'aria-live': 'polite' },
431
- count + ' ' + (count === 1 ? noun : plural) + ' selected'),
432
- ...actions.map((a, i) => Btn({
433
- key: 'bba' + i, danger: !!a.danger, disabled: busy || a.disabled,
434
- onClick: a.onClick, children: a.label,
435
- })),
436
- onClear ? Btn({ key: 'bbclear', disabled: busy, onClick: onClear, children: 'clear selection' }) : null,
437
- ].filter(Boolean);
438
- return h('div', { class: 'ds-bulkbar', role: 'toolbar', 'aria-label': 'bulk file actions', 'aria-busy': busy ? 'true' : null }, ...kids);
439
- }
440
-
441
- // Clickable column headers for FileGrid sort. Active column shows its direction
442
- // as an ASCII caret word (asc/desc) - never a glyph arrow.
443
- function FileSortHeader({ key: active = 'name', dir = 'asc', onSort } = {}) {
444
- const cols = [['name', 'name'], ['size', 'size'], ['modified', 'modified']];
445
- return h('div', { class: 'ds-file-sort', role: 'group', 'aria-label': 'sort files' },
446
- ...cols.map(([k, label]) => h('button', {
447
- key: k, type: 'button',
448
- class: 'ds-file-sort-btn' + (active === k ? ' active' : ''),
449
- 'aria-pressed': active === k ? 'true' : 'false',
450
- 'aria-label': 'sort by ' + label + (active === k ? ' (' + (dir === 'asc' ? 'ascending' : 'descending') + ')' : ''),
451
- onclick: () => onSort && onSort(k),
452
- }, label + (active === k ? ' ' + (dir === 'asc' ? 'asc' : 'desc') : ''))));
453
- }
454
-
455
- export function FileToolbar({ left = [], right = [] } = {}) {
456
- return h('div', { class: 'ds-file-toolbar' },
457
- h('div', { class: 'ds-file-toolbar-left' }, ...left),
458
- h('div', { class: 'ds-file-toolbar-right' }, ...right)
459
- );
460
- }
461
-
462
- // RootsPicker — a segmented control for choosing among multiple allowed FS roots
463
- // (so the app stops borrowing the history-tab .pill markup). Each root is
464
- // { id, label }; `selected` is the active id. role=tablist for AT navigation.
465
- export function RootsPicker({ roots = [], selected, onSelect, label = 'roots' } = {}) {
466
- if (!roots.length) return null;
467
- return h('div', { class: 'ds-roots-picker', role: 'tablist', 'aria-label': label },
468
- ...roots.map((r) => h('button', {
469
- key: 'root-' + (r.id != null ? r.id : r.label),
470
- type: 'button', role: 'tab',
471
- class: 'ds-roots-tab' + ((r.id != null ? r.id : r.label) === selected ? ' active' : ''),
472
- 'aria-selected': (r.id != null ? r.id : r.label) === selected ? 'true' : 'false',
473
- onclick: () => onSelect && onSelect(r.id != null ? r.id : r.label),
474
- }, r.label || r.id)));
475
- }
476
-
477
- export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave, label = 'drop files here', onPick } = {}) {
478
- // With children the zone is a passive WRAPPER: content renders normally and
479
- // the dashed affordance appears only while a drag is over it (real file
480
- // managers never burn a permanent band on a maybe-drop). Without children
481
- // it keeps the explicit picker-block look.
482
- const kids = Array.isArray(children) ? children : children ? [children] : [];
483
- return h('div', {
484
- class: 'ds-dropzone' + (kids.length ? ' ds-dropzone--wrap' : '') + (dragover ? ' dragover' : ''),
485
- ondragover: (e) => { e.preventDefault(); onDragOver && onDragOver(e); },
486
- ondragleave: (e) => { if (!e.currentTarget.contains(e.relatedTarget)) { onDragLeave && onDragLeave(e); } },
487
- ondrop: (e) => { e.preventDefault(); onDrop && onDrop(e.dataTransfer.files); }
488
- },
489
- h('div', { class: 'ds-dropzone-inner' },
490
- h('span', { class: 'ds-dropzone-glyph', role: 'img', 'aria-label': 'upload' }, Icon('arrow-up')),
491
- h('span', { class: 'ds-dropzone-label' }, label),
492
- onPick ? Btn({ onClick: onPick, children: 'pick files' }) : null
493
- ),
494
- ...kids
495
- );
496
- }
497
-
498
- // UploadProgress — per-file upload rows. Error rows are recoverable, not dead
499
- // ends: each item may carry `actions` ([{ label, onClick }], e.g. 'replace' on
500
- // a 409 collision) and the host may wire `onDismiss(item, index)` so error rows
501
- // can be cleared without waiting for the next successful batch.
502
- export function UploadProgress({ items = [], onDismiss } = {}) {
503
- if (!items.length) return null;
504
- return h('div', { class: 'ds-upload-progress' },
505
- ...items.map((it, i) => {
506
- const indeterminate = !it.error && !it.done && !it.pct && it.indeterminate;
507
- const status = it.error ? 'error' : (it.done ? 'complete' : (indeterminate ? 'uploading' : `uploading ${it.pct || 0}%`));
508
- const rowActions = [
509
- ...((it.actions || []).map((a, ai) => h('button', {
510
- key: 'ua' + ai, type: 'button', class: 'ds-upload-act',
511
- 'aria-label': `${a.label} ${it.name}`,
512
- onclick: () => a.onClick && a.onClick(it, i),
513
- }, a.label))),
514
- (it.error && onDismiss) ? h('button', {
515
- key: 'ud', type: 'button', class: 'ds-upload-act',
516
- 'aria-label': `dismiss ${it.name}`,
517
- onclick: () => onDismiss(it, i),
518
- }, 'dismiss') : null,
519
- ].filter(Boolean);
520
- return h('div', {
521
- key: it.name + i,
522
- class: 'ds-upload-item' + (it.done ? ' done' : '') + (it.error ? ' error' : ''),
523
- role: 'status',
524
- 'aria-label': `${it.name}: ${status}`,
525
- 'aria-live': 'polite'
526
- },
527
- h('span', { class: 'ds-upload-name' }, it.name),
528
- h('span', { class: 'ds-upload-bar' + (indeterminate ? ' indeterminate' : '') },
529
- h('span', { class: 'ds-upload-fill', 'data-pct': String(Math.max(0, Math.min(100, it.pct || 0))), 'aria-hidden': 'true' })
530
- ),
531
- h('span', { class: 'ds-upload-pct', 'aria-hidden': 'true' }, (it.error ? 'err' : (it.done ? 'ok' : (indeterminate ? '...' : (it.pct || 0) + '%')))),
532
- rowActions.length ? h('span', { class: 'ds-upload-actions', role: 'group', 'aria-label': `actions for ${it.name}` }, ...rowActions) : null
533
- );
534
- })
535
- );
536
- }
537
-
538
- export function EmptyState({ text = 'nothing here', glyph = Icon('circle'), action } = {}) {
539
- // action: { onClick, label } - an optional CTA (e.g. 'go up' / 'upload a
540
- // file'), mirroring the SessionDashboard emptyAction contract so an empty
541
- // directory is not a dead end. Children are built as an array + filtered so
542
- // the keyed Btn never sits beside an unkeyed span (webjsx applyDiff 'key'
543
- // crash on mixed keyed/unkeyed siblings).
544
- return h('div', { class: 'ds-file-empty', role: 'status' },
545
- ...[
546
- h('span', { key: 'glyph', class: 'ds-file-empty-glyph', 'aria-hidden': 'true' }, glyph),
547
- h('span', { key: 'text', class: 'ds-file-empty-text' }, text),
548
- (action && action.onClick)
549
- ? Btn({ key: 'ea', onClick: action.onClick, children: action.label || 'go up' })
550
- : null,
551
- ].filter(Boolean)
552
- );
553
- }
554
-
555
- export function BreadcrumbPath({ segments = [], onNav, root = 'root' } = {}) {
556
- const parts = [h('button', { key: 'root', class: 'ds-crumb-seg', onclick: () => onNav && onNav(0) }, root)];
557
- segments.forEach((seg, i) => {
558
- parts.push(h('span', { key: 'sep' + i, class: 'ds-crumb-sep', 'aria-hidden': 'true' }, Icon('chevron-right', { size: 13 })));
559
- parts.push(h('button', {
560
- key: 'seg' + i,
561
- class: 'ds-crumb-seg' + (i === segments.length - 1 ? ' leaf' : ''),
562
- onclick: () => onNav && onNav(i + 1)
563
- }, seg));
564
- });
565
- return h('div', { class: 'ds-crumb-path' }, ...parts);
566
- }