anentrypoint-design 0.0.207 → 0.0.209
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/app-shell.css +358 -96
- package/chat.css +99 -26
- package/colors_and_type.css +34 -1
- package/dist/247420.css +490 -123
- package/dist/247420.js +14 -14
- package/package.json +4 -2
- package/src/components/agent-chat.js +8 -2
- package/src/components/chat.js +26 -9
- package/src/components/content.js +28 -3
- package/src/components/context-pane.js +9 -9
- package/src/components/files-modals.js +3 -3
- package/src/components/files.js +181 -30
- package/src/components/freddie.js +25 -25
- package/src/components/sessions.js +37 -23
- package/src/components/shell.js +7 -1
- package/src/components/theme-toggle.js +12 -5
- package/src/components.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.209",
|
|
4
4
|
"description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/247420.js",
|
|
@@ -67,7 +67,9 @@
|
|
|
67
67
|
],
|
|
68
68
|
"scripts": {
|
|
69
69
|
"lint:tokens": "node scripts/lint-tokens.mjs",
|
|
70
|
-
"build": "node scripts/build.mjs"
|
|
70
|
+
"build": "node scripts/build.mjs",
|
|
71
|
+
"lint:null-children": "node scripts/lint-null-children.mjs",
|
|
72
|
+
"lint:classes": "node scripts/lint-classes.mjs"
|
|
71
73
|
},
|
|
72
74
|
"repository": {
|
|
73
75
|
"type": "git",
|
|
@@ -14,6 +14,7 @@ import * as webjsx from '../../vendor/webjsx/index.js';
|
|
|
14
14
|
import { ChatComposer, ChatMessage, makeThreadAutoScroll } from './chat.js';
|
|
15
15
|
import { Select } from './content.js';
|
|
16
16
|
import { Btn, Icon } from './shell.js';
|
|
17
|
+
import { initializeCachesEagerly } from '../markdown-cache.js';
|
|
17
18
|
|
|
18
19
|
const h = webjsx.createElement;
|
|
19
20
|
|
|
@@ -119,7 +120,7 @@ function CwdBar({ cwd, editing, draft, onEdit, onSave, onCancel, onClear, onDraf
|
|
|
119
120
|
'aria-describedby': hint ? 'agentchat-cwd-hint' : null,
|
|
120
121
|
'aria-invalid': error ? 'true' : null,
|
|
121
122
|
oninput: (e) => onDraft && onDraft(e.target.value) }),
|
|
122
|
-
Btn({ key: 'save',
|
|
123
|
+
Btn({ key: 'save', variant: 'primary', disabled: !!(error || checking), onClick: () => onSave && onSave(), children: 'save' }),
|
|
123
124
|
Btn({ key: 'cancel', onClick: () => onCancel && onCancel(), children: 'cancel' }),
|
|
124
125
|
hint ? h('span', { key: 'hint', id: 'agentchat-cwd-hint', role: 'status', 'aria-live': 'polite',
|
|
125
126
|
class: 'agentchat-cwd-hint' + (error ? ' is-error' : ' is-checking') }, hint) : null);
|
|
@@ -158,6 +159,11 @@ export function AgentChat(props = {}) {
|
|
|
158
159
|
shownMessages, onShowEarlier,
|
|
159
160
|
} = props;
|
|
160
161
|
|
|
162
|
+
// Warm the markdown/Prism stack the moment the surface mounts so the CDN
|
|
163
|
+
// round-trip never starts mid-first-response. Self-idempotent (internal
|
|
164
|
+
// _initPromise), so the per-render call is free after the first.
|
|
165
|
+
initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
|
|
166
|
+
|
|
161
167
|
const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
|
|
162
168
|
const lastIdx = messages.length - 1;
|
|
163
169
|
const lastMsg = messages[lastIdx];
|
|
@@ -251,7 +257,7 @@ export function AgentChat(props = {}) {
|
|
|
251
257
|
}
|
|
252
258
|
return ChatMessage({
|
|
253
259
|
key: m.id || String(i),
|
|
254
|
-
|
|
260
|
+
role: isAssistant ? 'assistant' : 'user',
|
|
255
261
|
// Claude-Code-web layout: flat full-width turns (no avatar disc, no colored
|
|
256
262
|
// bubble), distinguished by a role label + a faint assistant background.
|
|
257
263
|
// aicat is left OFF so the mascot tint never reaches the agent surface.
|
package/src/components/chat.js
CHANGED
|
@@ -164,6 +164,11 @@ function MdNode(p) {
|
|
|
164
164
|
const srcKey = (isMarkdownDegraded() ? '~degraded~' : '') + (p.text || '');
|
|
165
165
|
if (el.dataset.mdSrc === srcKey) return;
|
|
166
166
|
el.dataset.mdSrc = srcKey;
|
|
167
|
+
// Markdown stack still loading (or down): paint the raw text
|
|
168
|
+
// synchronously so streamed tokens are visible the same frame they
|
|
169
|
+
// arrive (an empty bubble until the CDN import resolves reads as a
|
|
170
|
+
// hang); the resolved render swaps in sanitized markdown in place.
|
|
171
|
+
if (isMarkdownDegraded()) el.textContent = p.text || '';
|
|
167
172
|
renderMarkdownCached(p.text || '').then((html) => { el.innerHTML = html; injectCodeCopy(el); });
|
|
168
173
|
};
|
|
169
174
|
return h('div', { class: 'chat-bubble chat-md', ref: refSink });
|
|
@@ -446,25 +451,37 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
446
451
|
// segment routes to the cwd editor WITHOUT making the whole line one giant
|
|
447
452
|
// click target. Legacy whole-line context.onClick is honored only when no
|
|
448
453
|
// bit carries its own handler. All children are keyed VElements.
|
|
449
|
-
|
|
450
|
-
|
|
454
|
+
// Normalize FIRST (a bit may carry `text` or `label`), then drop empties -
|
|
455
|
+
// separators must only ever sit between bits that actually render. An
|
|
456
|
+
// object bit whose text resolved empty used to leave a dangling trailing
|
|
457
|
+
// middot AND an invisible zero-width button.
|
|
458
|
+
const ctxBits = ((context && context.bits) ? context.bits : [])
|
|
459
|
+
.map((b) => {
|
|
460
|
+
if (b == null) return null;
|
|
461
|
+
if (typeof b === 'object') {
|
|
462
|
+
const text = b.text || b.label || '';
|
|
463
|
+
return text ? { text, onClick: b.onClick, title: b.title } : null;
|
|
464
|
+
}
|
|
465
|
+
const text = String(b);
|
|
466
|
+
return text ? { text } : null;
|
|
467
|
+
})
|
|
468
|
+
.filter(Boolean);
|
|
469
|
+
const hasBitClicks = ctxBits.some((b) => b.onClick);
|
|
451
470
|
let contextLine = null;
|
|
452
471
|
if (ctxBits.length && hasBitClicks) {
|
|
453
472
|
const kids = [];
|
|
454
473
|
ctxBits.forEach((b, i) => {
|
|
455
474
|
if (i) kids.push(h('span', { key: 'csep' + i, class: 'chat-composer-context-sep', 'aria-hidden': 'true' }, ' · '));
|
|
456
|
-
|
|
457
|
-
const text = isObj ? (b.text || '') : String(b);
|
|
458
|
-
if (isObj && b.onClick) kids.push(h('button', {
|
|
475
|
+
if (b.onClick) kids.push(h('button', {
|
|
459
476
|
key: 'cbit' + i, type: 'button', class: 'chat-composer-context-bit',
|
|
460
|
-
title: b.title || null, 'aria-label': b.title || text,
|
|
477
|
+
title: b.title || null, 'aria-label': b.title || b.text,
|
|
461
478
|
onclick: (e) => { e.preventDefault(); b.onClick(e); },
|
|
462
|
-
}, text));
|
|
463
|
-
else kids.push(h('span', { key: 'cbit' + i, class: 'chat-composer-context-text' }, text));
|
|
479
|
+
}, b.text));
|
|
480
|
+
else kids.push(h('span', { key: 'cbit' + i, class: 'chat-composer-context-text' }, b.text));
|
|
464
481
|
});
|
|
465
482
|
contextLine = h('div', { class: 'chat-composer-context' }, ...kids);
|
|
466
483
|
} else if (ctxBits.length) {
|
|
467
|
-
const joined = ctxBits.map((b) =>
|
|
484
|
+
const joined = ctxBits.map((b) => b.text).join(' · ');
|
|
468
485
|
contextLine = h(context.onClick ? 'button' : 'div', {
|
|
469
486
|
class: 'chat-composer-context', type: context.onClick ? 'button' : null,
|
|
470
487
|
'aria-label': context.onClick ? ('change target: ' + joined) : null,
|
|
@@ -183,7 +183,7 @@ export function WorksList({ works = [], openedIndex = -1, onToggle }) {
|
|
|
183
183
|
h('p', { class: 'ds-work-body' }, w.body)
|
|
184
184
|
),
|
|
185
185
|
h('div', { class: 'ds-work-actions' },
|
|
186
|
-
Btn({
|
|
186
|
+
Btn({ variant: 'primary', href: w.href || '#', children: 'open ->' }),
|
|
187
187
|
Btn({ href: w.source || '#', children: 'source' })
|
|
188
188
|
)
|
|
189
189
|
) : null
|
|
@@ -302,11 +302,24 @@ export function ProjectView({ project = {}, copied, onCopy } = {}) {
|
|
|
302
302
|
].filter(Boolean).flat();
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
export function PageHeader({ title, lede, eyebrow, right, compact, id }) {
|
|
305
|
+
export function PageHeader({ title, lede, eyebrow, right, compact, dense, id }) {
|
|
306
306
|
// `compact` drops the large leading/trailing section margins so a PageHeader
|
|
307
307
|
// used as a page's first element top-aligns cleanly without the consumer
|
|
308
308
|
// having to !important-override the .ds-section margin. `id` lands on the
|
|
309
309
|
// outermost section so the header can serve as a deep-link anchor.
|
|
310
|
+
// `dense` is the content-first working-surface form: one row - a small
|
|
311
|
+
// heading with the lede beside it, clamped to a single muted line - instead
|
|
312
|
+
// of a display H1 over a paragraph. App surfaces (files, dashboards,
|
|
313
|
+
// settings) should not spend 150px of fold on an intro.
|
|
314
|
+
if (dense) {
|
|
315
|
+
return h('section', { class: 'ds-section ds-section-compact ds-page-header-dense', id: id || null },
|
|
316
|
+
h('div', { class: 'ds-page-header-dense-row' },
|
|
317
|
+
...[
|
|
318
|
+
title != null ? h('h1', { key: 'dh' }, title) : null,
|
|
319
|
+
lede != null ? h('span', { key: 'dl', class: 'ds-page-header-dense-lede', title: typeof lede === 'string' ? lede : null }, lede) : null,
|
|
320
|
+
right != null ? h('div', { key: 'dr', class: 'ds-page-header-right' }, ...(Array.isArray(right) ? right : [right])) : null,
|
|
321
|
+
].filter(Boolean)));
|
|
322
|
+
}
|
|
310
323
|
return h('section', { class: 'ds-section' + (compact ? ' ds-section-compact' : ''), id: id || null },
|
|
311
324
|
eyebrow ? h('span', { class: 'eyebrow' }, eyebrow) : null,
|
|
312
325
|
title != null ? h('h1', {}, title) : null,
|
|
@@ -379,8 +392,20 @@ export function Select({ label, value = '', options = [], onChange, name, key, p
|
|
|
379
392
|
);
|
|
380
393
|
}
|
|
381
394
|
|
|
382
|
-
export function EventList({ items, events, emptyText = 'no events', rankPad = 3 }) {
|
|
395
|
+
export function EventList({ items, events, emptyText = 'no events', rankPad = 3, loading = false, loadingText = 'loading events…' }) {
|
|
383
396
|
const list = items || events || [];
|
|
397
|
+
// Shape-matched skeleton rows for the slow first events fetch (the ccsniff
|
|
398
|
+
// cold walk can take 30-90s) - a lone spinner collapses the whole pane.
|
|
399
|
+
// Keying discipline mirrors ConversationList: a single keyed wrapper with
|
|
400
|
+
// all-keyed siblings (webjsx applyDiff crashes on mixed keyed/unkeyed).
|
|
401
|
+
if (loading && !list.length) {
|
|
402
|
+
return h('section', { class: 'ds-section ds-event-list' },
|
|
403
|
+
h('div', { key: 'st', role: 'status', 'aria-live': 'polite', class: 'ds-event-state lede' }, loadingText),
|
|
404
|
+
...Array.from({ length: 7 }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-event-row-skeleton', 'aria-hidden': 'true' },
|
|
405
|
+
h('span', { key: 'r', class: 'ds-skel ds-skel-rank' }),
|
|
406
|
+
h('span', { key: 't', class: 'ds-skel ds-skel-title' }),
|
|
407
|
+
h('span', { key: 'm', class: 'ds-skel ds-skel-meta' }))));
|
|
408
|
+
}
|
|
384
409
|
if (!list.length) return h('p', { class: 'lede' }, emptyText);
|
|
385
410
|
return h('section', { class: 'ds-section ds-event-list' },
|
|
386
411
|
...list.map((it, i) => Row({
|
|
@@ -59,6 +59,10 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
|
|
|
59
59
|
// Use the rail tone consistently with the GUI-wide semantics:
|
|
60
60
|
// green = active/ok. A default cwd carries no rail (neutral).
|
|
61
61
|
rail: cwd ? 'green' : null,
|
|
62
|
+
// The change-cwd affordance belongs ON the working-dir fact,
|
|
63
|
+
// not as a button floating under the panels.
|
|
64
|
+
onClick: onSetCwd || undefined,
|
|
65
|
+
meta: onSetCwd ? 'change' : undefined,
|
|
62
66
|
}),
|
|
63
67
|
Row({
|
|
64
68
|
title: 'running tools',
|
|
@@ -69,8 +73,9 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
|
|
|
69
73
|
}),
|
|
70
74
|
];
|
|
71
75
|
// Conversation block: whole-session totals (turn count + accumulated cost)
|
|
72
|
-
// between the context panel and the per-turn usage panel.
|
|
73
|
-
|
|
76
|
+
// between the context panel and the per-turn usage panel. All-zero totals
|
|
77
|
+
// are noise, not context - hide the block until there is a conversation.
|
|
78
|
+
if (hasSession && (Number(session.turns) > 0 || Number(session.cost) > 0)) {
|
|
74
79
|
const sesRows = [];
|
|
75
80
|
if (session.turns != null) sesRows.push(Row({ title: 'turns', meta: String(session.turns) }));
|
|
76
81
|
if (session.cost != null) sesRows.push(Row({ title: 'total cost', meta: '$' + Number(session.cost).toFixed(4) }));
|
|
@@ -88,11 +93,6 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
|
|
|
88
93
|
if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: fmtDuration(usage.durationMs) }));
|
|
89
94
|
panels.push(Panel({ title: 'last turn', children: tokRows }));
|
|
90
95
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
onSetCwd
|
|
94
|
-
? h('div', { class: 'ds-context-actions' },
|
|
95
|
-
Btn({ onClick: onSetCwd, children: 'set working dir' }))
|
|
96
|
-
: null,
|
|
97
|
-
);
|
|
96
|
+
// The cwd action lives on the working-dir row above; no floating footer button.
|
|
97
|
+
return h('div', { class: 'ds-context' }, ...panels);
|
|
98
98
|
}
|
|
@@ -151,7 +151,7 @@ function modalError(error) {
|
|
|
151
151
|
// `error` renders inside .ds-modal-body (role=alert, error tone). `busy`
|
|
152
152
|
// disables both action buttons AND the Escape/backdrop close paths; the confirm
|
|
153
153
|
// label flips to `busyLabel` (default 'working…') so the in-flight state reads.
|
|
154
|
-
export function ConfirmDialog({ title = '
|
|
154
|
+
export function ConfirmDialog({ title = 'Are you sure?', message, confirmLabel = 'confirm', cancelLabel = 'cancel', destructive, onConfirm, onCancel, error, busy = false, busyLabel = 'working…' } = {}) {
|
|
155
155
|
return Modal({
|
|
156
156
|
onClose: onCancel,
|
|
157
157
|
kind: 'small',
|
|
@@ -160,12 +160,12 @@ export function ConfirmDialog({ title = 'confirm', message, confirmLabel = 'conf
|
|
|
160
160
|
body: [message || '', modalError(error)].filter(Boolean),
|
|
161
161
|
actions: [
|
|
162
162
|
Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
|
|
163
|
-
Btn({
|
|
163
|
+
Btn({ variant: 'primary', disabled: busy, onClick: onConfirm, children: busy ? busyLabel : confirmLabel })
|
|
164
164
|
]
|
|
165
165
|
});
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
export function PromptDialog({ title = 'name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput, error, busy = false, busyLabel = 'working…' } = {}) {
|
|
168
|
+
export function PromptDialog({ title = 'Enter a name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput, error, busy = false, busyLabel = 'working…' } = {}) {
|
|
169
169
|
return Modal({
|
|
170
170
|
onClose: onCancel,
|
|
171
171
|
kind: 'small',
|
package/src/components/files.js
CHANGED
|
@@ -51,7 +51,7 @@ export function FileIcon({ type = 'other' } = {}) {
|
|
|
51
51
|
const FILE_ROW_ACTIONS = ['download', 'rename', 'delete'];
|
|
52
52
|
|
|
53
53
|
export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked,
|
|
54
|
-
actions = FILE_ROW_ACTIONS, busy = false } = {}) {
|
|
54
|
+
actions = FILE_ROW_ACTIONS, busy = false, selectable = false, marked = false, onMark } = {}) {
|
|
55
55
|
// permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
|
|
56
56
|
// be listed (the dir stat saw it) but not opened — show an ASCII tag and
|
|
57
57
|
// disable the open button so the row reads honestly instead of silently
|
|
@@ -85,17 +85,27 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
|
|
|
85
85
|
actions.indexOf('delete') !== -1
|
|
86
86
|
? actBtn('delete', 'delete', `delete ${name}`, 'x', true) : null,
|
|
87
87
|
].filter(Boolean) : [];
|
|
88
|
+
// Multi-select checkbox — a sibling control before the open button so the
|
|
89
|
+
// row stays valid HTML (no interactive nesting). A no-access entry cannot
|
|
90
|
+
// be marked (bulk mutations would fail on it anyway).
|
|
91
|
+
const checkCtl = selectable ? h('button', {
|
|
92
|
+
key: 'mark',
|
|
93
|
+
type: 'button',
|
|
94
|
+
class: 'ds-file-check' + (marked ? ' is-marked' : ''),
|
|
95
|
+
role: 'checkbox',
|
|
96
|
+
'aria-checked': marked ? 'true' : 'false',
|
|
97
|
+
'aria-label': (marked ? 'unselect ' : 'select ') + name,
|
|
98
|
+
disabled: (noAccess || busy) ? true : null,
|
|
99
|
+
onclick: onMark ? (e) => onMark({ range: !!e.shiftKey }) : null,
|
|
100
|
+
}, h('span', { 'aria-hidden': 'true' }, marked ? '[x]' : '[ ]')) : null;
|
|
88
101
|
// A role=button row containing real <button> action controls is invalid
|
|
89
102
|
// HTML (interactive nesting). Instead the row is a plain container and the
|
|
90
103
|
// primary "open" affordance is itself a real <button> (native keyboard +
|
|
91
104
|
// semantics); the per-file action buttons sit alongside it as siblings.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
|
|
95
|
-
'data-file-type': type,
|
|
96
|
-
'aria-busy': busy ? 'true' : null,
|
|
97
|
-
},
|
|
105
|
+
const rowKids = [
|
|
106
|
+
checkCtl,
|
|
98
107
|
h('button', {
|
|
108
|
+
key: 'open',
|
|
99
109
|
type: 'button',
|
|
100
110
|
class: 'ds-file-open',
|
|
101
111
|
onclick: canOpen ? onOpen : null,
|
|
@@ -108,10 +118,17 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
|
|
|
108
118
|
h('span', { class: 'title' }, name),
|
|
109
119
|
h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—')
|
|
110
120
|
),
|
|
111
|
-
actionBtns.length ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
|
|
121
|
+
actionBtns.length ? h('span', { key: 'acts', class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
|
|
112
122
|
...actionBtns
|
|
113
|
-
) : null
|
|
114
|
-
);
|
|
123
|
+
) : null,
|
|
124
|
+
].filter(Boolean);
|
|
125
|
+
return h('div', {
|
|
126
|
+
key,
|
|
127
|
+
class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : '')
|
|
128
|
+
+ (marked ? ' is-marked' : '') + (selectable ? ' is-selectable' : ''),
|
|
129
|
+
'data-file-type': type,
|
|
130
|
+
'aria-busy': busy ? 'true' : null,
|
|
131
|
+
}, ...rowKids);
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
// FileSkeleton — placeholder shimmer rows shown while a directory loads, so the
|
|
@@ -162,16 +179,29 @@ const FILE_GRID_CAP = 200;
|
|
|
162
179
|
|
|
163
180
|
export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet',
|
|
164
181
|
columns = 'auto', sort, filter, loading = false,
|
|
165
|
-
shown, onShowMore, actions, busy
|
|
166
|
-
|
|
182
|
+
shown, onShowMore, actions, busy,
|
|
183
|
+
// Canonical multi-select contract (shared with
|
|
184
|
+
// SessionDashboard): selected/onToggleSelect.
|
|
185
|
+
// marked/onMark are accepted FileGrid aliases.
|
|
186
|
+
selectable = false, selected, onToggleSelect,
|
|
187
|
+
marked = selected, onMark = onToggleSelect,
|
|
188
|
+
onSelectAll, onClearSelection,
|
|
189
|
+
density = 'list', onDensity, thumbUrl } = {}) {
|
|
190
|
+
// Skeleton ONLY for a cold load. A refresh of a populated grid (rename /
|
|
191
|
+
// delete / upload round-trip) keeps the rows on screen and dims them -
|
|
192
|
+
// flashing the whole directory to shimmer rows on every mutation reads as
|
|
193
|
+
// data loss.
|
|
194
|
+
if (loading && !files.length) return FileSkeleton({});
|
|
167
195
|
if (!files.length) return EmptyState({ text: emptyText });
|
|
196
|
+
const refreshing = loading && files.length > 0;
|
|
168
197
|
// Cap the rendered rows. `shown` (host-controlled) overrides the default cap
|
|
169
198
|
// so "show more" can grow it; otherwise default to FILE_GRID_CAP.
|
|
170
199
|
const limit = shown != null ? shown : FILE_GRID_CAP;
|
|
171
200
|
const capped = files.length > limit;
|
|
172
201
|
const visible = capped ? files.slice(0, limit) : files;
|
|
202
|
+
const isThumb = density === 'thumb';
|
|
173
203
|
const gridAttrs = {};
|
|
174
|
-
if (columns !== 'auto' && columns > 0) {
|
|
204
|
+
if (!isThumb && columns !== 'auto' && columns > 0) {
|
|
175
205
|
const col = Math.max(1, Math.min(4, Math.floor(columns)));
|
|
176
206
|
gridAttrs['data-columns'] = String(col);
|
|
177
207
|
gridAttrs.style = {
|
|
@@ -180,19 +210,59 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
|
|
|
180
210
|
gap: 'var(--space-3)'
|
|
181
211
|
};
|
|
182
212
|
}
|
|
183
|
-
//
|
|
213
|
+
// Multi-select bookkeeping. Entries are keyed by path (fallback name); a
|
|
214
|
+
// locked/EACCES entry is never selectable — bulk mutations would fail on it.
|
|
215
|
+
const entryKeyOf = (f) => f.path || f.name;
|
|
216
|
+
const isLockedEntry = (f) => f.locked || f.permissions === 'EACCES'
|
|
217
|
+
|| (Array.isArray(f.permissions) && f.permissions.length === 0);
|
|
218
|
+
const selSet = marked instanceof Set ? marked : new Set(marked || []);
|
|
219
|
+
const selectableKeys = selectable ? visible.filter((f) => !isLockedEntry(f)).map(entryKeyOf) : [];
|
|
220
|
+
// Keyboard: roving focus over the open buttons inside the grid (rows and
|
|
221
|
+
// thumbnail cells share the pattern). Ctrl/Cmd+A selects all SHOWN rows.
|
|
184
222
|
const onKeyDown = (e) => {
|
|
185
223
|
const grid = e.currentTarget;
|
|
186
|
-
const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled])'));
|
|
187
|
-
if (!opens.length) return;
|
|
224
|
+
const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled]), .ds-file-cell-open:not([disabled])'));
|
|
188
225
|
const cur = opens.indexOf(document.activeElement);
|
|
189
226
|
if (e.key === 'ArrowDown') { e.preventDefault(); opens[Math.min(opens.length - 1, cur + 1)]?.focus(); }
|
|
190
227
|
else if (e.key === 'ArrowUp') { e.preventDefault(); (cur <= 0 ? opens[0] : opens[cur - 1])?.focus(); }
|
|
191
228
|
else if (e.key === 'Home') { e.preventDefault(); opens[0]?.focus(); }
|
|
192
229
|
else if (e.key === 'End') { e.preventDefault(); opens[opens.length - 1]?.focus(); }
|
|
193
230
|
else if (e.key === 'Backspace') { e.preventDefault(); onUp && onUp(); }
|
|
231
|
+
else if ((e.key === 'a' || e.key === 'A') && (e.ctrlKey || e.metaKey)
|
|
232
|
+
&& selectable && onSelectAll && selectableKeys.length) {
|
|
233
|
+
e.preventDefault(); onSelectAll(selectableKeys);
|
|
234
|
+
}
|
|
194
235
|
};
|
|
195
236
|
const head = sort ? FileSortHeader(sort) : null;
|
|
237
|
+
// Tri-state select-all over the selectable SHOWN rows (the cap label below
|
|
238
|
+
// already tells the user more rows exist beyond the window).
|
|
239
|
+
const selOfVisible = selectableKeys.filter((k) => selSet.has(k)).length;
|
|
240
|
+
const allState = selOfVisible === 0 ? 'false' : (selOfVisible === selectableKeys.length ? 'true' : 'mixed');
|
|
241
|
+
const selectAllCtl = (selectable && onSelectAll && selectableKeys.length)
|
|
242
|
+
? h('button', { key: 'selall', type: 'button', class: 'ds-file-selectall', role: 'checkbox',
|
|
243
|
+
'aria-checked': allState,
|
|
244
|
+
'aria-label': allState === 'true' ? 'clear selection' : 'select all ' + selectableKeys.length + ' shown files',
|
|
245
|
+
onclick: () => (allState === 'true' && onClearSelection) ? onClearSelection() : onSelectAll(selectableKeys) },
|
|
246
|
+
h('span', { 'aria-hidden': 'true' }, allState === 'true' ? '[x]' : allState === 'mixed' ? '[-]' : '[ ]'),
|
|
247
|
+
h('span', {}, 'all'))
|
|
248
|
+
: null;
|
|
249
|
+
// Density picker — list / compact / thumbnails. A radiogroup, not tabs:
|
|
250
|
+
// it switches presentation of the same content, not panels.
|
|
251
|
+
const densityCtl = onDensity
|
|
252
|
+
? h('div', { key: 'density', class: 'ds-density', role: 'radiogroup', 'aria-label': 'view density' },
|
|
253
|
+
...DENSITIES.map(([k, label]) => h('button', {
|
|
254
|
+
key: 'd-' + k, type: 'button', role: 'radio',
|
|
255
|
+
class: 'ds-density-btn' + (density === k ? ' active' : ''),
|
|
256
|
+
'aria-checked': density === k ? 'true' : 'false',
|
|
257
|
+
onclick: () => { if (density !== k) onDensity(k); },
|
|
258
|
+
}, label)))
|
|
259
|
+
: null;
|
|
260
|
+
const controlsKids = [selectAllCtl, head,
|
|
261
|
+
(selectAllCtl || head) && densityCtl ? h('span', { key: 'spread', class: 'spread' }) : null,
|
|
262
|
+
densityCtl].filter(Boolean);
|
|
263
|
+
const controls = controlsKids.length
|
|
264
|
+
? h('div', { class: 'ds-file-controls' }, ...controlsKids)
|
|
265
|
+
: null;
|
|
196
266
|
const filterBar = filter ? h('div', { class: 'ds-file-filter' },
|
|
197
267
|
h('input', {
|
|
198
268
|
class: 'ds-file-filter-input', type: 'search',
|
|
@@ -203,16 +273,33 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
|
|
|
203
273
|
// role=group not listbox: the rows contain real <button> action controls, so
|
|
204
274
|
// listbox/option semantics are invalid (an option can't host interactive
|
|
205
275
|
// children). Keyboard nav still works via roving focus over the open buttons.
|
|
206
|
-
const grid = h('div', {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
276
|
+
const grid = h('div', {
|
|
277
|
+
class: 'ds-file-grid' + (isThumb ? ' ds-file-grid-thumb' : '') + (refreshing ? ' is-refreshing' : ''),
|
|
278
|
+
role: 'group', 'aria-label': 'files', tabindex: '0',
|
|
279
|
+
'aria-busy': refreshing ? 'true' : 'false',
|
|
280
|
+
// Always concrete (webjsx's attribute diff can leave a null-valued
|
|
281
|
+
// attribute unset when toggling away from the default).
|
|
282
|
+
'data-density': density || 'list',
|
|
283
|
+
onkeydown: onKeyDown, ...gridAttrs },
|
|
284
|
+
...visible.map((f, i) => isThumb
|
|
285
|
+
? FileCell({
|
|
286
|
+
key: f.path || f.name + i, f,
|
|
287
|
+
selectable, marked: selSet.has(entryKeyOf(f)),
|
|
288
|
+
onMark: onMark ? (opts) => onMark(f, opts) : null,
|
|
289
|
+
onOpen,
|
|
290
|
+
thumb: (thumbUrl && f.type === 'image') ? thumbUrl(f) : null,
|
|
291
|
+
})
|
|
292
|
+
: FileRow({
|
|
293
|
+
key: f.path || f.name + i,
|
|
294
|
+
name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
|
|
295
|
+
permissions: f.permissions, locked: f.locked,
|
|
296
|
+
actions: actions != null ? actions : undefined,
|
|
297
|
+
busy: busy != null ? !!busy : !!f.busy,
|
|
298
|
+
selectable, marked: selSet.has(entryKeyOf(f)),
|
|
299
|
+
onMark: onMark ? (opts) => onMark(f, opts) : null,
|
|
300
|
+
onOpen: onOpen ? () => onOpen(f) : null,
|
|
301
|
+
onAction: onAction ? (act) => onAction(act, f) : null
|
|
302
|
+
}))
|
|
216
303
|
);
|
|
217
304
|
// A count + "show more" affordance so a capped large dir reads as "more
|
|
218
305
|
// exist", not "this is everything". aria-live announces the shown/total.
|
|
@@ -224,11 +311,70 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
|
|
|
224
311
|
onclick: () => onShowMore(Math.min(files.length, limit + FILE_GRID_CAP)) },
|
|
225
312
|
'show ' + Math.min(FILE_GRID_CAP, files.length - limit) + ' more') : null)
|
|
226
313
|
: null;
|
|
227
|
-
return (
|
|
228
|
-
? h('div', { class: 'ds-file-listing' }, filterBar,
|
|
314
|
+
return (controls || filterBar || more)
|
|
315
|
+
? h('div', { class: 'ds-file-listing' }, filterBar, controls, grid, more)
|
|
229
316
|
: grid;
|
|
230
317
|
}
|
|
231
318
|
|
|
319
|
+
const DENSITIES = [['list', 'list'], ['compact', 'compact'], ['thumb', 'thumbnails']];
|
|
320
|
+
|
|
321
|
+
// FileCell — the thumbnail-density tile. Image entries show a real (lazy)
|
|
322
|
+
// thumbnail through the host's confined thumbUrl; everything else keeps its
|
|
323
|
+
// type icon. Same open/mark semantics as FileRow, same no-nesting rule.
|
|
324
|
+
function FileCell({ key, f = {}, selectable = false, marked = false, onMark, onOpen, thumb } = {}) {
|
|
325
|
+
const noAccess = f.locked || f.permissions === 'EACCES'
|
|
326
|
+
|| (Array.isArray(f.permissions) && f.permissions.length === 0);
|
|
327
|
+
const canOpen = onOpen && !noAccess;
|
|
328
|
+
const typeLabel = TYPE_LABELS[f.type] || 'file';
|
|
329
|
+
const kids = [
|
|
330
|
+
selectable ? h('button', {
|
|
331
|
+
key: 'mark', type: 'button',
|
|
332
|
+
class: 'ds-file-check ds-file-cell-check' + (marked ? ' is-marked' : ''),
|
|
333
|
+
role: 'checkbox', 'aria-checked': marked ? 'true' : 'false',
|
|
334
|
+
'aria-label': (marked ? 'unselect ' : 'select ') + f.name,
|
|
335
|
+
disabled: noAccess ? true : null,
|
|
336
|
+
onclick: onMark ? (e) => onMark({ range: !!e.shiftKey }) : null,
|
|
337
|
+
}, h('span', { 'aria-hidden': 'true' }, marked ? '[x]' : '[ ]')) : null,
|
|
338
|
+
h('button', {
|
|
339
|
+
key: 'open', type: 'button', class: 'ds-file-cell-open',
|
|
340
|
+
onclick: canOpen ? () => onOpen(f) : null,
|
|
341
|
+
disabled: canOpen ? null : true,
|
|
342
|
+
'aria-label': typeLabel + ': ' + f.name + (noAccess ? ' (no access)' : ''),
|
|
343
|
+
},
|
|
344
|
+
h('span', { class: 'ds-file-cell-media' },
|
|
345
|
+
thumb
|
|
346
|
+
? h('img', { class: 'ds-file-cell-thumb', src: thumb, alt: '', loading: 'lazy' })
|
|
347
|
+
: FileIcon({ type: f.type })),
|
|
348
|
+
h('span', { class: 'ds-file-cell-name', title: f.name }, f.name),
|
|
349
|
+
h('span', { class: 'ds-file-cell-meta' }, f.type === 'dir' ? 'folder' : fmtFileSize(f.size))),
|
|
350
|
+
].filter(Boolean);
|
|
351
|
+
return h('div', {
|
|
352
|
+
key,
|
|
353
|
+
class: 'ds-file-cell' + (marked ? ' is-marked' : '') + (f.active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
|
|
354
|
+
'data-file-type': f.type,
|
|
355
|
+
}, ...kids);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// BulkBar — the act-on-selection strip shown while a multi-select is active.
|
|
359
|
+
// Host renders it above the grid; `actions` are [{ label, onClick, danger,
|
|
360
|
+
// disabled }]; `busy` disables everything while a bulk operation is in flight.
|
|
361
|
+
export function BulkBar({ count = 0, noun = 'file', nounPlural, actions = [], onClear, busy = false } = {}) {
|
|
362
|
+
if (!count) return null;
|
|
363
|
+
// 'entry' pluralizes to 'entries', not 'entrys' - handle the -y noun class
|
|
364
|
+
// unless the host passes an explicit plural.
|
|
365
|
+
const plural = nounPlural || (/[^aeiou]y$/.test(noun) ? noun.slice(0, -1) + 'ies' : noun + 's');
|
|
366
|
+
const kids = [
|
|
367
|
+
h('span', { key: 'count', class: 'ds-bulkbar-count', role: 'status', 'aria-live': 'polite' },
|
|
368
|
+
count + ' ' + (count === 1 ? noun : plural) + ' selected'),
|
|
369
|
+
...actions.map((a, i) => Btn({
|
|
370
|
+
key: 'bba' + i, danger: !!a.danger, disabled: busy || a.disabled,
|
|
371
|
+
onClick: a.onClick, children: a.label,
|
|
372
|
+
})),
|
|
373
|
+
onClear ? Btn({ key: 'bbclear', disabled: busy, onClick: onClear, children: 'clear selection' }) : null,
|
|
374
|
+
].filter(Boolean);
|
|
375
|
+
return h('div', { class: 'ds-bulkbar', role: 'toolbar', 'aria-label': 'bulk file actions', 'aria-busy': busy ? 'true' : null }, ...kids);
|
|
376
|
+
}
|
|
377
|
+
|
|
232
378
|
// Clickable column headers for FileGrid sort. Active column shows its direction
|
|
233
379
|
// as an ASCII caret word (asc/desc) - never a glyph arrow.
|
|
234
380
|
function FileSortHeader({ key: active = 'name', dir = 'asc', onSort } = {}) {
|
|
@@ -266,8 +412,13 @@ export function RootsPicker({ roots = [], selected, onSelect, label = 'roots' }
|
|
|
266
412
|
}
|
|
267
413
|
|
|
268
414
|
export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave, label = 'drop files here', onPick } = {}) {
|
|
415
|
+
// With children the zone is a passive WRAPPER: content renders normally and
|
|
416
|
+
// the dashed affordance appears only while a drag is over it (real file
|
|
417
|
+
// managers never burn a permanent band on a maybe-drop). Without children
|
|
418
|
+
// it keeps the explicit picker-block look.
|
|
419
|
+
const kids = Array.isArray(children) ? children : children ? [children] : [];
|
|
269
420
|
return h('div', {
|
|
270
|
-
class: 'ds-dropzone' + (dragover ? ' dragover' : ''),
|
|
421
|
+
class: 'ds-dropzone' + (kids.length ? ' ds-dropzone--wrap' : '') + (dragover ? ' dragover' : ''),
|
|
271
422
|
ondragover: (e) => { e.preventDefault(); onDragOver && onDragOver(e); },
|
|
272
423
|
ondragleave: (e) => { onDragLeave && onDragLeave(e); },
|
|
273
424
|
ondrop: (e) => { e.preventDefault(); onDrop && onDrop(e.dataTransfer.files); }
|
|
@@ -277,7 +428,7 @@ export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave,
|
|
|
277
428
|
h('span', { class: 'ds-dropzone-label' }, label),
|
|
278
429
|
onPick ? Btn({ onClick: onPick, children: 'pick files' }) : null
|
|
279
430
|
),
|
|
280
|
-
...
|
|
431
|
+
...kids
|
|
281
432
|
);
|
|
282
433
|
}
|
|
283
434
|
|