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.
- package/dist/247420.css +33 -0
- package/dist/247420.js +53 -51
- package/package.json +1 -1
- package/src/components/agent-chat/controls.js +143 -0
- package/src/components/agent-chat/empty-state.js +63 -0
- package/src/components/agent-chat/message-rows.js +141 -0
- package/src/components/agent-chat/surface.js +206 -0
- package/src/components/agent-chat/thread-behaviour.js +50 -0
- package/src/components/agent-chat.js +6 -546
- package/src/components/chat/composer-affordances.js +109 -0
- package/src/components/chat/composer.js +256 -0
- package/src/components/chat/threads.js +105 -0
- package/src/components/chat-message-parts/agent-nodes.js +103 -0
- package/src/components/chat-message-parts/inline.js +106 -0
- package/src/components/chat-message-parts/prose-nodes.js +133 -0
- package/src/components/chat-message-parts/renderers.js +89 -0
- package/src/components/chat-message-parts.js +12 -408
- package/src/components/chat-minimap/minimap.js +167 -0
- package/src/components/chat-minimap/paint.js +73 -0
- package/src/components/chat-minimap/preview.js +41 -0
- package/src/components/chat-minimap.js +7 -263
- package/src/components/chat.js +20 -628
- package/src/components/community/chrome.js +63 -0
- package/src/components/community/navigation.js +167 -0
- package/src/components/community/presence.js +106 -0
- package/src/components/community/shell.js +17 -0
- package/src/components/community/views.js +131 -0
- package/src/components/community.js +18 -447
- package/src/components/files/chrome.js +140 -0
- package/src/components/files/entries.js +156 -0
- package/src/components/files/grid-controls.js +63 -0
- package/src/components/files/grid.js +177 -0
- package/src/components/files/types.js +69 -0
- package/src/components/files-modals/dialogs.js +118 -0
- package/src/components/files-modals/modal-shell.js +153 -0
- package/src/components/files-modals/preview-bodies.js +129 -0
- package/src/components/files-modals/preview-containers.js +96 -0
- package/src/components/files-modals.js +14 -475
- package/src/components/files.js +15 -564
- package/src/components/freddie/pages-config.js +3 -94
- package/src/components/freddie/pages-models.js +97 -0
- package/src/components/freddie.js +2 -1
- package/src/components/interaction-primitives/mobile.js +25 -0
- package/src/components/interaction-primitives/pointer.js +214 -0
- package/src/components/interaction-primitives/reorderable.js +47 -0
- package/src/components/interaction-primitives/shortcuts.js +119 -0
- package/src/components/interaction-primitives.js +16 -388
- package/src/components/sessions/conversation-list.js +230 -0
- package/src/components/sessions/dashboard.js +202 -0
- package/src/components/sessions/detail-bits.js +49 -0
- package/src/components/sessions/format.js +42 -0
- package/src/components/sessions/session-card.js +92 -0
- package/src/components/sessions.js +16 -579
- package/src/components/voice/audio-cue.js +39 -0
- package/src/components/voice/capture.js +90 -0
- package/src/components/voice/playback.js +75 -0
- package/src/components/voice/settings-modal.js +104 -0
- package/src/components/voice.js +16 -293
- package/src/css/app-shell/base.css +23 -0
- package/src/css/app-shell/states-interactions.css +10 -0
- package/src/kits/os/freddie/chat-protocol.js +32 -0
- package/src/kits/os/freddie/chat-transport.js +178 -0
- package/src/kits/os/freddie/pages-chat.js +10 -180
- package/src/kits/os/shell-chrome.js +163 -0
- package/src/kits/os/shell-geometry.js +59 -0
- package/src/kits/os/shell.js +178 -335
- package/src/page-html/client-script.js +151 -0
- package/src/page-html/head-tags.js +59 -0
- package/src/page-html/markdown.js +68 -0
- package/src/page-html/page-styles.js +44 -0
- package/src/page-html.js +14 -291
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Per-entry file primitives: FileRow (list/compact density), FileCell
|
|
2
|
+
// (thumbnail density), and the loading skeleton. Both entry shapes share the
|
|
3
|
+
// same open/select/permission semantics and the same no-interactive-nesting
|
|
4
|
+
// rule — the row is a plain container whose "open" affordance is itself a real
|
|
5
|
+
// <button>, with per-file action buttons as siblings.
|
|
6
|
+
|
|
7
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
8
|
+
import { Icon } from '../shell.js';
|
|
9
|
+
import { TYPE_LABELS, FileIcon, fmtFileSize } from './types.js';
|
|
10
|
+
const h = webjsx.createElement;
|
|
11
|
+
|
|
12
|
+
// Default action set for FileRow. A host without mutation endpoints passes a
|
|
13
|
+
// narrower `actions` list (e.g. ['download']) so the row renders no dead controls.
|
|
14
|
+
const FILE_ROW_ACTIONS = ['download', 'rename', 'move', 'delete'];
|
|
15
|
+
|
|
16
|
+
export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked,
|
|
17
|
+
actions = FILE_ROW_ACTIONS, busy = false, selectable = false, selected = false, onToggleSelect } = {}) {
|
|
18
|
+
// permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
|
|
19
|
+
// be listed (the dir stat saw it) but not opened — show an ASCII tag and
|
|
20
|
+
// disable the open button so the row reads honestly instead of silently
|
|
21
|
+
// failing on click.
|
|
22
|
+
const noAccess = locked || permissions === 'EACCES' || (Array.isArray(permissions) && permissions.length === 0);
|
|
23
|
+
const readOnly = !noAccess && Array.isArray(permissions) && permissions.indexOf('write') === -1 && permissions.indexOf('read') !== -1;
|
|
24
|
+
const permTag = noAccess ? 'no access' : (readOnly ? 'read-only' : null);
|
|
25
|
+
// permTag is rendered as its own chip (a SHAPE channel, not folded into the
|
|
26
|
+
// muted meta text) - so drop it from the meta join, but keep it in the
|
|
27
|
+
// accessible label so AT still announces the restriction.
|
|
28
|
+
const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null].filter(Boolean).join(' · ');
|
|
29
|
+
const typeLabel = TYPE_LABELS[type] || 'file';
|
|
30
|
+
const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}${permTag ? ', ' + permTag : ''}`;
|
|
31
|
+
const canOpen = onOpen && !noAccess && !busy;
|
|
32
|
+
// Mutation actions on a read-only/no-access row render disabled (with a
|
|
33
|
+
// 'read-only' title) instead of vanishing, so the affordance reads honestly.
|
|
34
|
+
// `busy` (in-flight mutation) disables every control on the row.
|
|
35
|
+
const mutateDisabled = busy || readOnly || noAccess;
|
|
36
|
+
const actBtn = (act, title, ariaLabel, icon, warn) => h('button', {
|
|
37
|
+
key: 'act-' + act,
|
|
38
|
+
type: 'button',
|
|
39
|
+
class: 'ds-file-act' + (warn ? ' ds-file-act-warn' : ''),
|
|
40
|
+
title: mutateDisabled && act !== 'download' ? 'read-only' : title,
|
|
41
|
+
'aria-label': ariaLabel,
|
|
42
|
+
disabled: (act === 'download' ? busy : mutateDisabled) ? true : null,
|
|
43
|
+
'aria-disabled': (act === 'download' ? busy : mutateDisabled) ? 'true' : null,
|
|
44
|
+
onclick: () => onAction(act),
|
|
45
|
+
}, Icon(icon));
|
|
46
|
+
const actionBtns = onAction ? [
|
|
47
|
+
actions.indexOf('download') !== -1 && type !== 'dir'
|
|
48
|
+
? actBtn('download', 'download', `download ${name}`, 'arrow-down', false) : null,
|
|
49
|
+
actions.indexOf('rename') !== -1
|
|
50
|
+
? actBtn('rename', 'rename', `rename ${name}`, 'pencil', false) : null,
|
|
51
|
+
// Single-file move used to require checkbox-select + BulkBar - a
|
|
52
|
+
// per-row affordance matches fsbrowse and the kit rename/delete rows
|
|
53
|
+
// already on this row (no reason move alone needed a select detour).
|
|
54
|
+
actions.indexOf('move') !== -1
|
|
55
|
+
? actBtn('move', 'move', `move ${name}`, 'arrow-right', false) : null,
|
|
56
|
+
actions.indexOf('delete') !== -1
|
|
57
|
+
? actBtn('delete', 'delete', `delete ${name}`, 'x', true) : null,
|
|
58
|
+
].filter(Boolean) : [];
|
|
59
|
+
// Multi-select checkbox — a sibling control before the open button so the
|
|
60
|
+
// row stays valid HTML (no interactive nesting). A no-access entry cannot
|
|
61
|
+
// be marked (bulk mutations would fail on it anyway).
|
|
62
|
+
const checkCtl = selectable ? h('button', {
|
|
63
|
+
key: 'mark',
|
|
64
|
+
type: 'button',
|
|
65
|
+
class: 'ds-file-check' + (selected ? ' is-marked' : ''),
|
|
66
|
+
role: 'checkbox',
|
|
67
|
+
'aria-checked': selected ? 'true' : 'false',
|
|
68
|
+
'aria-label': (selected ? 'unselect ' : 'select ') + name,
|
|
69
|
+
disabled: (noAccess || busy) ? true : null,
|
|
70
|
+
onclick: onToggleSelect ? (e) => onToggleSelect({ range: !!e.shiftKey }) : null,
|
|
71
|
+
}, h('span', { class: 'ds-check-box', 'aria-hidden': 'true' })) : null;
|
|
72
|
+
// A role=button row containing real <button> action controls is invalid
|
|
73
|
+
// HTML (interactive nesting). Instead the row is a plain container and the
|
|
74
|
+
// primary "open" affordance is itself a real <button> (native keyboard +
|
|
75
|
+
// semantics); the per-file action buttons sit alongside it as siblings.
|
|
76
|
+
const rowKids = [
|
|
77
|
+
checkCtl,
|
|
78
|
+
h('button', {
|
|
79
|
+
key: 'open',
|
|
80
|
+
type: 'button',
|
|
81
|
+
class: 'ds-file-open',
|
|
82
|
+
onclick: canOpen ? onOpen : null,
|
|
83
|
+
'aria-label': accessibleLabel + (noAccess ? ' (no access)' : ''),
|
|
84
|
+
'aria-pressed': active ? 'true' : 'false',
|
|
85
|
+
disabled: canOpen ? null : true,
|
|
86
|
+
},
|
|
87
|
+
...[
|
|
88
|
+
code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
|
|
89
|
+
FileIcon({ type }),
|
|
90
|
+
h('span', { class: 'title' }, name),
|
|
91
|
+
h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—'),
|
|
92
|
+
permTag ? h('span', { class: 'ds-file-perm-tag' + (noAccess ? ' is-noaccess' : ''), 'aria-hidden': 'true' }, permTag) : null,
|
|
93
|
+
].filter(Boolean)
|
|
94
|
+
),
|
|
95
|
+
actionBtns.length ? h('span', { key: 'acts', class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
|
|
96
|
+
...actionBtns
|
|
97
|
+
) : null,
|
|
98
|
+
].filter(Boolean);
|
|
99
|
+
return h('div', {
|
|
100
|
+
key,
|
|
101
|
+
class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : '')
|
|
102
|
+
+ (readOnly ? ' is-restricted' : '')
|
|
103
|
+
+ (selected ? ' is-marked' : '') + (selectable ? ' is-selectable' : ''),
|
|
104
|
+
'data-file-type': type,
|
|
105
|
+
'aria-busy': busy ? 'true' : null,
|
|
106
|
+
}, ...rowKids);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// FileCell — the thumbnail-density tile. Image entries show a real (lazy)
|
|
110
|
+
// thumbnail through the host's confined thumbUrl; everything else keeps its
|
|
111
|
+
// type icon. Same open/mark semantics as FileRow, same no-nesting rule.
|
|
112
|
+
export function FileCell({ key, f = {}, selectable = false, selected = false, onToggleSelect, onOpen, thumb } = {}) {
|
|
113
|
+
const noAccess = f.locked || f.permissions === 'EACCES'
|
|
114
|
+
|| (Array.isArray(f.permissions) && f.permissions.length === 0);
|
|
115
|
+
const canOpen = onOpen && !noAccess;
|
|
116
|
+
const typeLabel = TYPE_LABELS[f.type] || 'file';
|
|
117
|
+
const kids = [
|
|
118
|
+
selectable ? h('button', {
|
|
119
|
+
key: 'mark', type: 'button',
|
|
120
|
+
class: 'ds-file-check ds-file-cell-check' + (selected ? ' is-marked' : ''),
|
|
121
|
+
role: 'checkbox', 'aria-checked': selected ? 'true' : 'false',
|
|
122
|
+
'aria-label': (selected ? 'unselect ' : 'select ') + f.name,
|
|
123
|
+
disabled: noAccess ? true : null,
|
|
124
|
+
onclick: onToggleSelect ? (e) => onToggleSelect({ range: !!e.shiftKey }) : null,
|
|
125
|
+
}, h('span', { class: 'ds-check-box', 'aria-hidden': 'true' })) : null,
|
|
126
|
+
h('button', {
|
|
127
|
+
key: 'open', type: 'button', class: 'ds-file-cell-open',
|
|
128
|
+
onclick: canOpen ? () => onOpen(f) : null,
|
|
129
|
+
disabled: canOpen ? null : true,
|
|
130
|
+
'aria-label': typeLabel + ': ' + f.name + (noAccess ? ' (no access)' : ''),
|
|
131
|
+
},
|
|
132
|
+
h('span', { class: 'ds-file-cell-media' },
|
|
133
|
+
thumb
|
|
134
|
+
? h('img', { class: 'ds-file-cell-thumb', src: thumb, alt: '', loading: 'lazy' })
|
|
135
|
+
: FileIcon({ type: f.type })),
|
|
136
|
+
h('span', { class: 'ds-file-cell-name', title: f.name }, f.name),
|
|
137
|
+
h('span', { class: 'ds-file-cell-meta' }, f.type === 'dir' ? 'folder' : fmtFileSize(f.size))),
|
|
138
|
+
].filter(Boolean);
|
|
139
|
+
return h('div', {
|
|
140
|
+
key,
|
|
141
|
+
class: 'ds-file-cell' + (selected ? ' is-marked' : '') + (f.active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
|
|
142
|
+
'data-file-type': f.type,
|
|
143
|
+
}, ...kids);
|
|
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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// FileGrid toolbar controls: the density radiogroup (list / compact /
|
|
2
|
+
// thumbnails), the clickable sort-column header, and the WAI-ARIA roving-radio
|
|
3
|
+
// keyboard helper both share.
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { Icon } from '../shell.js';
|
|
7
|
+
const h = webjsx.createElement;
|
|
8
|
+
|
|
9
|
+
export const DENSITIES = [['list', 'list'], ['compact', 'compact'], ['thumb', 'thumbnails']];
|
|
10
|
+
export const DENSITY_ICONS = { list: 'rows', compact: 'rows-tight', thumb: 'grid' };
|
|
11
|
+
|
|
12
|
+
// Roving-radiogroup keyboard helper (the WAI-ARIA radio pattern): a radiogroup
|
|
13
|
+
// is a SINGLE tab stop where Arrow/Home/End move AND select among options, with
|
|
14
|
+
// selection following focus. `items` is the ordered [[key, ...], ...] list;
|
|
15
|
+
// `onSelect(targetKey)` is the same handler the onclick fires. Mouse path is
|
|
16
|
+
// unchanged - this only adds keyboard navigation.
|
|
17
|
+
export function rovingRadio(e, idx, items, onSelect) {
|
|
18
|
+
let target = -1;
|
|
19
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') target = (idx - 1 + items.length) % items.length;
|
|
20
|
+
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') target = (idx + 1) % items.length;
|
|
21
|
+
else if (e.key === 'Home') target = 0;
|
|
22
|
+
else if (e.key === 'End') target = items.length - 1;
|
|
23
|
+
else return;
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
onSelect(items[target][0]);
|
|
26
|
+
const sib = e.currentTarget.parentNode && e.currentTarget.parentNode.children[target];
|
|
27
|
+
sib && sib.focus();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Clickable column headers for FileGrid sort. Active column shows its direction
|
|
31
|
+
// as an ASCII caret word (asc/desc) - never a glyph arrow.
|
|
32
|
+
export function FileSortHeader({ key: active = 'name', dir = 'asc', onSort } = {}) {
|
|
33
|
+
const cols = [['name', 'name'], ['size', 'size'], ['modified', 'modified']];
|
|
34
|
+
return h('div', { class: 'ds-file-sort', role: 'group', 'aria-label': 'sort files' },
|
|
35
|
+
...cols.map(([k, label]) => h('button', {
|
|
36
|
+
key: k, type: 'button',
|
|
37
|
+
class: 'ds-file-sort-btn' + (active === k ? ' active' : ''),
|
|
38
|
+
'aria-pressed': active === k ? 'true' : 'false',
|
|
39
|
+
'aria-label': 'sort by ' + label + (active === k ? ' (' + (dir === 'asc' ? 'ascending' : 'descending') + ')' : ''),
|
|
40
|
+
onclick: () => onSort && onSort(k),
|
|
41
|
+
}, label + (active === k ? ' ' + (dir === 'asc' ? 'asc' : 'desc') : ''))));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Density picker — list / compact / thumbnails. A radiogroup, not tabs:
|
|
45
|
+
// it switches presentation of the same content, not panels.
|
|
46
|
+
export function DensityPicker({ density = 'list', onDensity } = {}) {
|
|
47
|
+
if (!onDensity) return null;
|
|
48
|
+
return h('div', { key: 'density', class: 'ds-density', role: 'radiogroup', 'aria-label': 'view density' },
|
|
49
|
+
...DENSITIES.map(([k, label], idx) => h('button', {
|
|
50
|
+
key: 'd-' + k, type: 'button', role: 'radio',
|
|
51
|
+
class: 'ds-density-btn' + (density === k ? ' active' : ''),
|
|
52
|
+
'aria-checked': density === k ? 'true' : 'false',
|
|
53
|
+
// Icon-led, but the density name stays the accessible name
|
|
54
|
+
// (aria-label) + the native tooltip (title) so the control reads
|
|
55
|
+
// dense without losing its label.
|
|
56
|
+
'aria-label': label, title: label,
|
|
57
|
+
// Single tab stop: the checked radio is tabbable, the rest are
|
|
58
|
+
// roved. Arrow/Home/End move + select (selection follows focus).
|
|
59
|
+
tabindex: density === k ? '0' : '-1',
|
|
60
|
+
onkeydown: (e) => rovingRadio(e, idx, DENSITIES, (tk) => { if (density !== tk) onDensity(tk); }),
|
|
61
|
+
onclick: () => { if (density !== k) onDensity(k); },
|
|
62
|
+
}, Icon(DENSITY_ICONS[k], { size: 15 }))));
|
|
63
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// FileGrid — the directory listing: cold-load skeleton, in-grid sort/filter
|
|
2
|
+
// toolbar, tri-state select-all, roving keyboard focus over the open buttons,
|
|
3
|
+
// a render cap with a "show N more" tail, and list/compact/thumb density.
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { Icon } from '../shell.js';
|
|
7
|
+
import { FileRow, FileCell, FileSkeleton } from './entries.js';
|
|
8
|
+
import { FileSortHeader, DensityPicker } from './grid-controls.js';
|
|
9
|
+
import { EmptyState } from './chrome.js';
|
|
10
|
+
const h = webjsx.createElement;
|
|
11
|
+
|
|
12
|
+
// FileGrid — the directory listing. Optional in-grid sort + filter make it a
|
|
13
|
+
// real file manager rather than a static dump:
|
|
14
|
+
// sort : { key, dir, onSort(key) } - clickable column headers (name/size/modified)
|
|
15
|
+
// filter : { value, onInput, placeholder } - a quick in-dir name filter
|
|
16
|
+
// onOpen(f) opens a row; onAction(act,f) wires the per-row download/rename/delete.
|
|
17
|
+
// Keyboard nav: the grid is a focusable listbox - ArrowUp/Down move the active
|
|
18
|
+
// row, Enter opens it, Backspace asks the host to go up (onUp). The host keeps no
|
|
19
|
+
// focus state; the grid tracks it on the DOM via roving tabindex.
|
|
20
|
+
// How many rows to render before the "show more" cap kicks in. A node_modules-
|
|
21
|
+
// scale directory would otherwise flood the DOM with thousands of rows (and make
|
|
22
|
+
// the roving-tabindex querySelectorAll scan O(n) per keypress). Render the first
|
|
23
|
+
// CAP and a "show N more" row, mirroring the History tab's "load N older".
|
|
24
|
+
const FILE_GRID_CAP = 200;
|
|
25
|
+
|
|
26
|
+
export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet', emptyAction,
|
|
27
|
+
sort, filter, loading = false,
|
|
28
|
+
shown, onShowMore, actions, busy,
|
|
29
|
+
// Canonical multi-select contract (shared with
|
|
30
|
+
// SessionDashboard): selected/onToggleSelect.
|
|
31
|
+
// marked/onMark are accepted FileGrid aliases.
|
|
32
|
+
selectable = false, selected, onToggleSelect,
|
|
33
|
+
marked = selected, onMark = onToggleSelect,
|
|
34
|
+
onSelectAll, onClearSelection,
|
|
35
|
+
density = 'list', onDensity, thumbUrl } = {}) {
|
|
36
|
+
// Skeleton ONLY for a cold load. A refresh of a populated grid (rename /
|
|
37
|
+
// delete / upload round-trip) keeps the rows on screen and dims them -
|
|
38
|
+
// flashing the whole directory to shimmer rows on every mutation reads as
|
|
39
|
+
// data loss.
|
|
40
|
+
if (loading && !files.length) return FileSkeleton({ rows: 12 });
|
|
41
|
+
// A filtered miss is NOT an empty directory: when the in-grid filter narrows
|
|
42
|
+
// to zero matches, the host still passes an empty `files` array - but we must
|
|
43
|
+
// keep the controls toolbar (the filter input that caused the miss) mounted so
|
|
44
|
+
// the user can clear/edit it to recover. Only a genuinely-empty directory (no
|
|
45
|
+
// active filter) gets the bare cold EmptyState early-return.
|
|
46
|
+
const hasFilter = !!(filter && (filter.value || '').length > 0);
|
|
47
|
+
if (!files.length && !hasFilter) return EmptyState({ text: emptyText, glyph: Icon('folder-open', { size: 28 }), action: emptyAction });
|
|
48
|
+
const refreshing = loading && files.length > 0;
|
|
49
|
+
// Cap the rendered rows. `shown` (host-controlled) overrides the default cap
|
|
50
|
+
// so "show more" can grow it; otherwise default to FILE_GRID_CAP.
|
|
51
|
+
const limit = shown != null ? shown : FILE_GRID_CAP;
|
|
52
|
+
const capped = files.length > limit;
|
|
53
|
+
const visible = capped ? files.slice(0, limit) : files;
|
|
54
|
+
const isThumb = density === 'thumb';
|
|
55
|
+
// NOTE: the old `columns`-driven data-columns card-mode was removed - it placed
|
|
56
|
+
// flex list-rows into a 2-4 col grid (squashed rows, mis-sized actions) and was
|
|
57
|
+
// a half-wired third layout never exposed by the density radiogroup (list/
|
|
58
|
+
// compact/thumb). Thumb density is the canonical multi-column grid.
|
|
59
|
+
const gridAttrs = {};
|
|
60
|
+
// Multi-select bookkeeping. Entries are keyed by path (fallback name); a
|
|
61
|
+
// locked/EACCES entry is never selectable — bulk mutations would fail on it.
|
|
62
|
+
const entryKeyOf = (f) => f.path || f.name;
|
|
63
|
+
const isLockedEntry = (f) => f.locked || f.permissions === 'EACCES'
|
|
64
|
+
|| (Array.isArray(f.permissions) && f.permissions.length === 0);
|
|
65
|
+
const selSet = marked instanceof Set ? marked : new Set(marked || []);
|
|
66
|
+
const selectableKeys = selectable ? visible.filter((f) => !isLockedEntry(f)).map(entryKeyOf) : [];
|
|
67
|
+
// Keyboard: roving focus over the open buttons inside the grid (rows and
|
|
68
|
+
// thumbnail cells share the pattern). Ctrl/Cmd+A selects all SHOWN rows.
|
|
69
|
+
const onKeyDown = (e) => {
|
|
70
|
+
const grid = e.currentTarget;
|
|
71
|
+
const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled]), .ds-file-cell-open:not([disabled])'));
|
|
72
|
+
const cur = opens.indexOf(document.activeElement);
|
|
73
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); opens[Math.min(opens.length - 1, cur + 1)]?.focus(); }
|
|
74
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); (cur <= 0 ? opens[0] : opens[cur - 1])?.focus(); }
|
|
75
|
+
else if (e.key === 'Home') { e.preventDefault(); opens[0]?.focus(); }
|
|
76
|
+
else if (e.key === 'End') { e.preventDefault(); opens[opens.length - 1]?.focus(); }
|
|
77
|
+
else if (e.key === 'Backspace') { e.preventDefault(); onUp && onUp(); }
|
|
78
|
+
else if ((e.key === 'a' || e.key === 'A') && (e.ctrlKey || e.metaKey)
|
|
79
|
+
&& selectable && onSelectAll && selectableKeys.length) {
|
|
80
|
+
e.preventDefault(); onSelectAll(selectableKeys);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const head = sort ? FileSortHeader(sort) : null;
|
|
84
|
+
// Tri-state select-all over the selectable SHOWN rows (the cap label below
|
|
85
|
+
// already tells the user more rows exist beyond the window).
|
|
86
|
+
const selOfVisible = selectableKeys.filter((k) => selSet.has(k)).length;
|
|
87
|
+
const allState = selOfVisible === 0 ? 'false' : (selOfVisible === selectableKeys.length ? 'true' : 'mixed');
|
|
88
|
+
const selectAllCtl = (selectable && onSelectAll && selectableKeys.length)
|
|
89
|
+
? h('button', { key: 'selall', type: 'button', class: 'ds-file-selectall', role: 'checkbox',
|
|
90
|
+
'aria-checked': allState,
|
|
91
|
+
'aria-label': allState === 'true' ? 'clear selection' : 'select all ' + selectableKeys.length + ' shown files',
|
|
92
|
+
onclick: () => (allState === 'true' && onClearSelection) ? onClearSelection() : onSelectAll(selectableKeys) },
|
|
93
|
+
h('span', { class: 'ds-check-box', 'aria-hidden': 'true' }),
|
|
94
|
+
h('span', {}, 'all'))
|
|
95
|
+
: null;
|
|
96
|
+
const densityCtl = DensityPicker({ density, onDensity });
|
|
97
|
+
// One toolbar baseline: filter + select-all + sort sit left, density is
|
|
98
|
+
// pushed right by the spread. The filter used to be a separate right-aligned
|
|
99
|
+
// strip ABOVE controls, giving two strips with conflicting alignment.
|
|
100
|
+
const filterCtl = filter ? h('span', { key: 'filterwrap', class: 'ds-file-filter-wrap' },
|
|
101
|
+
h('input', {
|
|
102
|
+
key: 'filter',
|
|
103
|
+
class: 'ds-file-filter-input', type: 'search',
|
|
104
|
+
value: filter.value || '', placeholder: filter.placeholder || 'Filter files',
|
|
105
|
+
'aria-label': filter.placeholder || 'Filter files in this directory',
|
|
106
|
+
oninput: (e) => filter.onInput && filter.onInput(e.target.value),
|
|
107
|
+
onkeydown: (e) => {
|
|
108
|
+
if (e.key === 'Escape' && filter.value) { e.preventDefault(); e.stopPropagation(); filter.onInput && filter.onInput(''); }
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
// Announces the filtered count as the filter narrows the list, so a
|
|
112
|
+
// screen-reader user gets the same feedback a sighted user reads off
|
|
113
|
+
// the grid without having to re-scan it after every keystroke. `files`
|
|
114
|
+
// here is already the host's filter-applied set (see hasFilter above) -
|
|
115
|
+
// there is no separate pre-filter total available inside this component.
|
|
116
|
+
h('span', { key: 'filtercount', class: 'sr-only', role: 'status', 'aria-live': 'polite' },
|
|
117
|
+
hasFilter ? files.length + (files.length === 1 ? ' file' : ' files') + ' shown' : '')
|
|
118
|
+
) : null;
|
|
119
|
+
const leftKids = [filterCtl, selectAllCtl, head].filter(Boolean);
|
|
120
|
+
const controlsKids = [
|
|
121
|
+
...leftKids,
|
|
122
|
+
(leftKids.length && densityCtl) ? h('span', { key: 'spread', class: 'spread' }) : null,
|
|
123
|
+
densityCtl].filter(Boolean);
|
|
124
|
+
const controls = controlsKids.length
|
|
125
|
+
? h('div', { class: 'ds-file-controls' }, ...controlsKids)
|
|
126
|
+
: null;
|
|
127
|
+
// A filtered miss (zero rows but an active filter) renders the EmptyState
|
|
128
|
+
// INSIDE the listing, below the controls toolbar, so the filter input stays
|
|
129
|
+
// mounted and editable - the user can clear/edit it to recover instead of
|
|
130
|
+
// being stranded with no toolbar (the early-return only fires for a genuinely
|
|
131
|
+
// empty directory). The host passes filter-aware copy via emptyText.
|
|
132
|
+
const filteredEmpty = !files.length && hasFilter;
|
|
133
|
+
// role=group not listbox: the rows contain real <button> action controls, so
|
|
134
|
+
// listbox/option semantics are invalid (an option can't host interactive
|
|
135
|
+
// children). Keyboard nav still works via roving focus over the open buttons.
|
|
136
|
+
const grid = filteredEmpty ? EmptyState({ text: emptyText, glyph: Icon('folder-open', { size: 28 }) }) : h('div', {
|
|
137
|
+
class: 'ds-file-grid' + (isThumb ? ' ds-file-grid-thumb' : '') + (refreshing ? ' is-refreshing' : ''),
|
|
138
|
+
role: 'group', 'aria-label': 'files', tabindex: '0',
|
|
139
|
+
'aria-busy': refreshing ? 'true' : 'false',
|
|
140
|
+
// Always concrete (webjsx's attribute diff can leave a null-valued
|
|
141
|
+
// attribute unset when toggling away from the default).
|
|
142
|
+
'data-density': density || 'list',
|
|
143
|
+
onkeydown: onKeyDown, ...gridAttrs },
|
|
144
|
+
...visible.map((f, i) => isThumb
|
|
145
|
+
? FileCell({
|
|
146
|
+
key: f.path || f.name + i, f,
|
|
147
|
+
selectable, selected: selSet.has(entryKeyOf(f)),
|
|
148
|
+
onToggleSelect: onMark ? (opts) => onMark(f, opts) : null,
|
|
149
|
+
onOpen,
|
|
150
|
+
thumb: (thumbUrl && f.type === 'image') ? thumbUrl(f) : null,
|
|
151
|
+
})
|
|
152
|
+
: FileRow({
|
|
153
|
+
key: f.path || f.name + i,
|
|
154
|
+
name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
|
|
155
|
+
permissions: f.permissions, locked: f.locked,
|
|
156
|
+
actions: actions != null ? actions : undefined,
|
|
157
|
+
busy: busy != null ? !!busy : !!f.busy,
|
|
158
|
+
selectable, selected: selSet.has(entryKeyOf(f)),
|
|
159
|
+
onToggleSelect: onMark ? (opts) => onMark(f, opts) : null,
|
|
160
|
+
onOpen: onOpen ? () => onOpen(f) : null,
|
|
161
|
+
onAction: onAction ? (act) => onAction(act, f) : null
|
|
162
|
+
}))
|
|
163
|
+
);
|
|
164
|
+
// A count + "show more" affordance so a capped large dir reads as "more
|
|
165
|
+
// exist", not "this is everything". aria-live announces the shown/total.
|
|
166
|
+
const more = capped
|
|
167
|
+
? h('div', { class: 'ds-file-more' },
|
|
168
|
+
h('span', { class: 'ds-file-more-count', role: 'status', 'aria-live': 'polite' },
|
|
169
|
+
'showing ' + visible.length + ' of ' + files.length),
|
|
170
|
+
onShowMore ? h('button', { type: 'button', class: 'ds-file-more-btn',
|
|
171
|
+
onclick: () => onShowMore(Math.min(files.length, limit + FILE_GRID_CAP)) },
|
|
172
|
+
'show ' + Math.min(FILE_GRID_CAP, files.length - limit) + ' more') : null)
|
|
173
|
+
: null;
|
|
174
|
+
return (controls || more)
|
|
175
|
+
? h('div', { class: 'ds-file-listing' }, controls, grid, more)
|
|
176
|
+
: grid;
|
|
177
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// File-type vocabulary: the canonical type list, its line-icon and accessible
|
|
2
|
+
// label maps, the FileIcon component, and the one byte formatter the whole kit
|
|
3
|
+
// renders sizes through (chat.js re-exports it as fmtBytes).
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { Icon } from '../shell.js';
|
|
7
|
+
const h = webjsx.createElement;
|
|
8
|
+
|
|
9
|
+
export const FILE_TYPES = ['dir', 'image', 'video', 'audio', 'code', 'text', 'archive', 'document', 'symlink', 'other'];
|
|
10
|
+
const TYPE_ICON = {
|
|
11
|
+
dir: 'folder', image: 'file-image', video: 'file-video', audio: 'file-audio', code: 'file-code',
|
|
12
|
+
text: 'file-text', archive: 'file-zip', document: 'file-text', symlink: 'link', other: 'file'
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const TYPE_LABELS = {
|
|
16
|
+
dir: 'folder',
|
|
17
|
+
image: 'image file',
|
|
18
|
+
video: 'video file',
|
|
19
|
+
audio: 'audio file',
|
|
20
|
+
code: 'code file',
|
|
21
|
+
text: 'text file',
|
|
22
|
+
archive: 'archive file',
|
|
23
|
+
document: 'document file',
|
|
24
|
+
symlink: 'symbolic link',
|
|
25
|
+
other: 'file'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function fileGlyph(type) {
|
|
29
|
+
return TYPE_ICON[type] || TYPE_ICON.other;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The canonical kit byte formatter (chat.js re-exports it as fmtBytes). One
|
|
33
|
+
// format everywhere: '0 B' for zero; the em-dash means unknown/null/invalid
|
|
34
|
+
// (NaN, a negative count, or anything non-numeric never reaches the divide
|
|
35
|
+
// loop — previously NaN fell through to the loop unchanged and rendered the
|
|
36
|
+
// literal string "NaN B").
|
|
37
|
+
export function fmtFileSize(bytes) {
|
|
38
|
+
if (bytes == null) return '—';
|
|
39
|
+
if (typeof bytes !== 'number' || Number.isNaN(bytes) || bytes < 0) return '—';
|
|
40
|
+
if (bytes === 0) return '0 B';
|
|
41
|
+
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
42
|
+
let i = 0, n = bytes;
|
|
43
|
+
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|
44
|
+
return n.toFixed(i === 0 ? 0 : 1) + ' ' + u[i];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function FileIcon({ type = 'other' } = {}) {
|
|
48
|
+
return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, Icon(fileGlyph(type)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Sort a file list by a key (name/size/modified/type), dirs-first always so the
|
|
52
|
+
// hierarchy reads top-down regardless of sort. `dir` is 'asc'|'desc'.
|
|
53
|
+
// `modifiedTs` (epoch ms) is used for the modified sort when present, since the
|
|
54
|
+
// `modified` field is a pre-formatted relative string the host passes for display.
|
|
55
|
+
export function sortFiles(files = [], sort = 'name', dir = 'asc') {
|
|
56
|
+
const mul = dir === 'desc' ? -1 : 1;
|
|
57
|
+
const cmp = (a, b) => {
|
|
58
|
+
// Directories always cluster before files; within a cluster, apply the sort.
|
|
59
|
+
const ad = a.type === 'dir' ? 0 : 1, bd = b.type === 'dir' ? 0 : 1;
|
|
60
|
+
if (ad !== bd) return ad - bd;
|
|
61
|
+
let r = 0;
|
|
62
|
+
if (sort === 'size') r = (a.size || 0) - (b.size || 0);
|
|
63
|
+
else if (sort === 'modified') r = (a.modifiedTs || 0) - (b.modifiedTs || 0);
|
|
64
|
+
else if (sort === 'type') r = String(a.type || '').localeCompare(String(b.type || ''));
|
|
65
|
+
else r = String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' });
|
|
66
|
+
return r * mul || String(a.name || '').localeCompare(String(b.name || ''));
|
|
67
|
+
};
|
|
68
|
+
return files.slice().sort(cmp);
|
|
69
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The three question dialogs, all composed on the shared Modal shell so they
|
|
2
|
+
// inherit its focus trap and Escape/backdrop dismiss: ConfirmDialog (yes/no,
|
|
3
|
+
// optionally destructive), PromptDialog (single text input with optional root
|
|
4
|
+
// chips), and CountdownDialog (ticking auto-expire).
|
|
5
|
+
|
|
6
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
7
|
+
import { Btn } from '../shell.js';
|
|
8
|
+
import { Modal, modalError } from './modal-shell.js';
|
|
9
|
+
const h = webjsx.createElement;
|
|
10
|
+
|
|
11
|
+
// `error` renders inside .ds-modal-body (role=alert, error tone). `busy`
|
|
12
|
+
// disables both action buttons AND the Escape/backdrop close paths; the confirm
|
|
13
|
+
// label flips to `busyLabel` (default 'working…') so the in-flight state reads.
|
|
14
|
+
export function ConfirmDialog({ title = 'Are you sure?', message, confirmLabel = 'confirm', cancelLabel = 'cancel', destructive, onConfirm, onCancel, error, busy = false, busyLabel = 'working…' } = {}) {
|
|
15
|
+
return Modal({
|
|
16
|
+
onClose: onCancel,
|
|
17
|
+
kind: 'small',
|
|
18
|
+
busy,
|
|
19
|
+
head: title,
|
|
20
|
+
body: [message || '', modalError(error)].filter(Boolean),
|
|
21
|
+
actions: [
|
|
22
|
+
Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
|
|
23
|
+
Btn({ variant: destructive ? 'danger' : 'primary', disabled: busy, onClick: onConfirm, children: busy ? busyLabel : confirmLabel })
|
|
24
|
+
]
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PromptDialog({ title = 'Enter a name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput, error, busy = false, busyLabel = 'working…', roots, onPickRoot } = {}) {
|
|
29
|
+
// Optional one-click starting-point chips (e.g. a destination-path prompt
|
|
30
|
+
// for a filesystem with more than one allowed root) - a user typing a
|
|
31
|
+
// path has no way to discover what a second disjoint root even looks
|
|
32
|
+
// like otherwise. Each { path, label } fills the input via the same
|
|
33
|
+
// onInput callback a manual keystroke would.
|
|
34
|
+
const rootsRow = (roots && roots.length)
|
|
35
|
+
? h('div', { class: 'ds-prompt-roots', role: 'group', 'aria-label': 'accessible folders' },
|
|
36
|
+
...roots.map((r, i) => h('button', {
|
|
37
|
+
key: 'pr' + i, type: 'button', class: 'ds-prompt-root-chip',
|
|
38
|
+
onclick: () => { const p = r.path || r; if (onPickRoot) onPickRoot(p); else if (onInput) onInput(p); },
|
|
39
|
+
}, r.label || r.path || r)))
|
|
40
|
+
: null;
|
|
41
|
+
return Modal({
|
|
42
|
+
onClose: onCancel,
|
|
43
|
+
kind: 'small',
|
|
44
|
+
busy,
|
|
45
|
+
head: title,
|
|
46
|
+
body: [h('input', {
|
|
47
|
+
class: 'input ds-modal-input',
|
|
48
|
+
type: 'text',
|
|
49
|
+
value,
|
|
50
|
+
placeholder,
|
|
51
|
+
autofocus: true,
|
|
52
|
+
disabled: busy ? true : null,
|
|
53
|
+
'aria-invalid': error ? 'true' : null,
|
|
54
|
+
oninput: (e) => onInput && onInput(e.target.value),
|
|
55
|
+
onkeydown: (e) => {
|
|
56
|
+
// IME guard: the Enter that commits a CJK composition must not confirm.
|
|
57
|
+
if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); if (!busy) onConfirm && onConfirm(e.target.value); }
|
|
58
|
+
if (e.key === 'Escape') { e.preventDefault(); if (!busy) onCancel && onCancel(); }
|
|
59
|
+
}
|
|
60
|
+
}), rootsRow, modalError(error)].filter(Boolean),
|
|
61
|
+
actions: [
|
|
62
|
+
Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
|
|
63
|
+
Btn({
|
|
64
|
+
primary: true,
|
|
65
|
+
disabled: busy,
|
|
66
|
+
// Read the live input value, not the closed-over `value` prop:
|
|
67
|
+
// consumers update their state in oninput without re-rendering
|
|
68
|
+
// (to avoid caret jump), so the prop is stale at click time.
|
|
69
|
+
onClick: (e) => {
|
|
70
|
+
if (!onConfirm) return;
|
|
71
|
+
const inp = e.currentTarget.closest('.ds-modal')?.querySelector('.ds-modal-input');
|
|
72
|
+
onConfirm(inp ? inp.value : value);
|
|
73
|
+
},
|
|
74
|
+
children: busy ? busyLabel : confirmLabel
|
|
75
|
+
})
|
|
76
|
+
]
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// CountdownDialog — a modal with a role=status line ticking down from
|
|
81
|
+
// `seconds` to 0 once per second, auto-firing onExpire at zero. Composes on
|
|
82
|
+
// top of the same Modal() shell ConfirmDialog/PromptDialog use, so it
|
|
83
|
+
// inherits Backdrop's focus-trap + Escape/backdrop-dismiss handling for free
|
|
84
|
+
// rather than reimplementing dialog plumbing.
|
|
85
|
+
export function CountdownDialog({ title = 'Are you sure?', message, seconds = 10, onExpire, actions } = {}) {
|
|
86
|
+
const startSeconds = Math.max(0, Math.floor(seconds));
|
|
87
|
+
return Modal({
|
|
88
|
+
onClose: undefined, // no implicit dismiss path unless the caller supplies one via `actions`
|
|
89
|
+
kind: 'small',
|
|
90
|
+
head: title,
|
|
91
|
+
body: [
|
|
92
|
+
message || '',
|
|
93
|
+
h('p', {
|
|
94
|
+
class: 'ds-countdown-status', role: 'status', 'aria-live': 'polite',
|
|
95
|
+
ref: (el) => {
|
|
96
|
+
if (!el || el._dsCountdownTimer) return;
|
|
97
|
+
let remaining = startSeconds;
|
|
98
|
+
const render = () => { el.textContent = remaining + (remaining === 1 ? ' second' : ' seconds') + ' remaining'; };
|
|
99
|
+
render();
|
|
100
|
+
el._dsCountdownTimer = setInterval(() => {
|
|
101
|
+
remaining -= 1;
|
|
102
|
+
if (remaining <= 0) {
|
|
103
|
+
clearInterval(el._dsCountdownTimer);
|
|
104
|
+
el._dsCountdownTimer = null;
|
|
105
|
+
remaining = 0;
|
|
106
|
+
render();
|
|
107
|
+
if (onExpire) onExpire();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
render();
|
|
111
|
+
}, 1000);
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
modalError(null),
|
|
115
|
+
].filter(Boolean),
|
|
116
|
+
actions: actions || [],
|
|
117
|
+
});
|
|
118
|
+
}
|