@hachej/boring-ui-kit 0.4.47 → 0.4.49

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 (50) hide show
  1. package/bin/boring-ui-blocks.mjs +33 -1
  2. package/dist/file-tree-view.d.ts +25 -2
  3. package/dist/file-tree-view.js +8 -4
  4. package/package.json +2 -2
  5. package/r/agent-target.json +4 -4
  6. package/r/app-shell.json +12 -12
  7. package/r/board-viewer.json +6 -6
  8. package/r/button.json +1 -1
  9. package/r/dock.json +1 -1
  10. package/r/documents.json +12 -12
  11. package/r/feedback.json +6 -6
  12. package/r/file-tree.json +5 -5
  13. package/r/highlights.json +3 -3
  14. package/r/html-viewer.json +3 -3
  15. package/r/looks-viewer.json +2 -2
  16. package/r/maquette-viewer.json +1 -1
  17. package/r/markdown-editor.json +3 -3
  18. package/r/mockup-viewer.json +1 -1
  19. package/r/pi-ambient.json +9 -9
  20. package/r/pi-app-kinds.json +10 -10
  21. package/r/pi-app.json +10 -10
  22. package/r/pi-artifacts.json +2 -2
  23. package/r/pi-chat-approval.json +2 -2
  24. package/r/pi-chat-attachments.json +1 -1
  25. package/r/pi-chat-markdown.json +2 -2
  26. package/r/pi-chat-mentions.json +1 -1
  27. package/r/pi-chat-pickers.json +1 -1
  28. package/r/pi-chat-plugins.json +11 -11
  29. package/r/pi-chat-question.json +1 -1
  30. package/r/pi-chat-slash.json +2 -2
  31. package/r/pi-chat-subagent.json +1 -1
  32. package/r/pi-chat-voice.json +3 -3
  33. package/r/pi-chat.json +9 -9
  34. package/r/pi-workspace.json +3 -3
  35. package/r/placed-agent.json +4 -4
  36. package/r/provider-setup.json +1 -1
  37. package/r/registry.json +161 -161
  38. package/r/shared-chat.json +6 -6
  39. package/r/spaces.json +1 -1
  40. package/r/task-list-viewer.json +2 -2
  41. package/r/version-history.json +2 -2
  42. package/r/viewer-code.json +2 -2
  43. package/r/viewer-frame.json +4 -4
  44. package/r/viewer-html.json +4 -4
  45. package/r/viewer-image.json +2 -2
  46. package/r/viewer-markdown.json +5 -5
  47. package/r/viewer-pdf.json +3 -3
  48. package/r/viewers.json +6 -6
  49. package/r/windows.json +1 -1
  50. package/styles/file-tree.css +4 -0
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // `check` writes nothing. `add`, `update` and `manifest` write the shadcn files they install and the manifest, nothing else.
12
12
  import { createHash } from 'node:crypto';
13
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
13
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
14
14
  import { dirname, join, relative, resolve, sep } from 'node:path';
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { fileURLToPath } from 'node:url';
@@ -307,6 +307,37 @@ function shadcnAdd(app, args, log, overwrite) {
307
307
  return { status: result.status ?? 1, skipped: skipped.map(([path]) => path), created };
308
308
  }
309
309
 
310
+ /**
311
+ * A package manager follows a symlinked `node_modules` or scope directory and writes into the tree it points at: another checkout's install.
312
+ * Refused before anything runs. Per-package links (workspaces, pnpm) stay valid, and `check` never gets here: it writes nothing.
313
+ */
314
+ function refuseBorrowedInstall(app) {
315
+ const borrowed = [];
316
+ for (const dir of installRoots(app)) {
317
+ const modules = join(dir, 'node_modules');
318
+ const stat = lstatSync(modules, { throwIfNoEntry: false });
319
+ if (stat?.isSymbolicLink()) borrowed.push(modules);
320
+ else if (stat) borrowed.push(...readdirSync(modules).filter(name => name.startsWith('@')).map(name => join(modules, name)).filter(path => lstatSync(path).isSymbolicLink()));
321
+ }
322
+ if (borrowed.length) throw new Error(`refusing to install through a borrowed install: ${borrowed.map(path => `${path} is a symlink`).join(', ')}. A package manager would write into the tree it points at. A borrowed install is read-only: \`check\` still works, and \`add\` and \`update\` need an owned physical install.`);
323
+ }
324
+
325
+ /** Where a package manager run from `app` may write: the app, and every directory up to its workspace root when it is in a workspace. */
326
+ function installRoots(app) {
327
+ const walked = [];
328
+ for (let dir = app; ; dir = dirname(dir)) {
329
+ walked.push(dir);
330
+ if (isWorkspaceRoot(dir)) return walked;
331
+ if (dirname(dir) === dir) return [app];
332
+ }
333
+ }
334
+
335
+ function isWorkspaceRoot(dir) {
336
+ if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true;
337
+ try { return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).workspaces !== undefined; }
338
+ catch { return false; }
339
+ }
340
+
310
341
  const line = v => `${v.check} ${v.rule} ${v.file}${v.line ? `:${v.line}:${v.column}` : ''} ${v.detail}`;
311
342
 
312
343
  export function main(argv, cwd = process.cwd(), log = console.log, error = console.error) {
@@ -328,6 +359,7 @@ export function main(argv, cwd = process.cwd(), log = console.log, error = conso
328
359
  return 1;
329
360
  }
330
361
  if (command === 'manifest') { log(`${flags.manifest ?? MANIFEST}: ${writeManifest(app, flags.manifest)} file(s)`); return 0; }
362
+ if (command === 'add' || (command === 'update' && !flags.dryRun)) refuseBorrowedInstall(app);
331
363
  if (command === 'add') {
332
364
  appPaths(app);
333
365
  if (!flags.pass.length) throw new Error('add needs shadcn add arguments');
@@ -1,6 +1,6 @@
1
1
  import type { Collapsible } from '@base-ui/react/collapsible';
2
2
  import type { ComponentProps, ReactNode } from 'react';
3
- import type { RevisionProvider, SavedRevision } from '@hachej/boring-files/revision';
3
+ import type { FileEntry, RevisionProvider, SavedRevision } from '@hachej/boring-files/revision';
4
4
  import type { FileTreeController } from './file-tree.js';
5
5
  /** Every word of the tree, so a host can translate it. Functions receive what they name. */
6
6
  export declare const defaultFileTreeLabels: {
@@ -109,6 +109,16 @@ export interface FileTreeDialogProps {
109
109
  readonly footer: ReactNode;
110
110
  }
111
111
  /** Host presentation. The registry supplies real shadcn Base UI controls and shared version rows; no primitive fallback. */
112
+ /** A row's status badge, as `decorate` gave it. */
113
+ export interface FileTreeBadgeProps {
114
+ readonly tone: FileDecorationTone;
115
+ readonly children: ReactNode;
116
+ readonly role: 'note';
117
+ readonly 'aria-label': string;
118
+ readonly title: string;
119
+ readonly 'data-file-badge': '';
120
+ readonly 'data-tone': FileDecorationTone;
121
+ }
112
122
  export interface FileTreeComponents {
113
123
  readonly Button: (props: FileTreeButtonProps) => ReactNode;
114
124
  readonly Actions: (props: FileTreeActionsProps) => ReactNode;
@@ -138,6 +148,8 @@ export interface FileTreeComponents {
138
148
  readonly value: number | null;
139
149
  readonly 'aria-label': string;
140
150
  }) => ReactNode;
151
+ /** The host's shadcn `Badge` for a row's status (`decorate`); without it the status is plain text. */
152
+ readonly Badge?: (props: FileTreeBadgeProps) => ReactNode;
141
153
  /** The host's shadcn `Dialog`. Without it the tree offers neither Delete nor Move to… (dragging a file onto a folder still moves it). */
142
154
  readonly Dialog?: (props: FileTreeDialogProps) => ReactNode;
143
155
  }
@@ -159,6 +171,15 @@ export type UploadBlob = (file: globalThis.File, options: {
159
171
  readonly signal: AbortSignal;
160
172
  readonly onProgress: (progress: UploadProgress) => void;
161
173
  }) => Promise<unknown>;
174
+ export type FileDecorationTone = 'neutral' | 'success' | 'warning' | 'danger';
175
+ /** A row's status from the host (D-1374): a badge, read with the file's name, and a short secondary text. */
176
+ export interface FileDecoration {
177
+ readonly badge?: {
178
+ readonly label: string;
179
+ readonly tone: FileDecorationTone;
180
+ };
181
+ readonly detail?: string;
182
+ }
162
183
  export interface FileTreeProps {
163
184
  readonly components: FileTreeComponents;
164
185
  readonly revisionProvider: RevisionProvider;
@@ -201,6 +222,8 @@ export interface FileTreeProps {
201
222
  readonly onRenamed?: (from: string, to: string) => void;
202
223
  /** A delete from the tree committed (a host showing `path` closes it). */
203
224
  readonly onDeleted?: (path: string) => void;
225
+ /** The host's status for a row (for example « Lu · 12 lignes »); nothing, or no prop, leaves the row as it is. */
226
+ readonly decorate?: (entry: FileEntry) => FileDecoration | null | undefined;
204
227
  }
205
228
  /** One borrowed binding powers browsing, search, upload and history. Historical previews never replace an editor's draft. */
206
229
  export declare function FileTree(props: FileTreeProps): import("react").JSX.Element;
@@ -208,5 +231,5 @@ export interface FileTreeViewProps extends Omit<FileTreeProps, 'revisionProvider
208
231
  readonly controller: FileTreeController;
209
232
  }
210
233
  /** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
211
- export declare function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refresh, variant, title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob, capabilities, onRenamed, onDeleted }: FileTreeViewProps): import("react").JSX.Element;
234
+ export declare function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refresh, variant, title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob, capabilities, onRenamed, onDeleted, decorate }: FileTreeViewProps): import("react").JSX.Element;
212
235
  //# sourceMappingURL=file-tree-view.d.ts.map
@@ -171,7 +171,7 @@ function BoundFileTree(props) {
171
171
  return owned?.provider === props.revisionProvider ? _jsx(FileTreeView, { ...props, controller: owned.controller }) : _jsx("p", { role: "status", children: props.labels?.loading ?? LOCALES[props.locale ?? 'en'].loading });
172
172
  }
173
173
  /** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
174
- export function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refresh = true, variant = 'list', title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob, capabilities, onRenamed, onDeleted }) {
174
+ export function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refresh = true, variant = 'list', title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob, capabilities, onRenamed, onDeleted, decorate }) {
175
175
  const changed = useRef({ onRenamed, onDeleted });
176
176
  changed.current = { onRenamed, onDeleted };
177
177
  /** Tell the host about a committed rename or delete, so a viewer showing the file follows or closes. */
@@ -184,7 +184,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
184
184
  changed.current.onDeleted?.(mutation.path);
185
185
  return mutation;
186
186
  };
187
- const { Button, Actions, Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Collapsible: CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, Empty, Progress, Dialog } = components;
187
+ const { Button, Actions, Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Collapsible: CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, Empty, Progress, Dialog, Badge } = components;
188
188
  const can = {
189
189
  // The client's methods turn the actions on; capabilities, when the host passes them, only narrow.
190
190
  rename: controller.actions.rename && (capabilities === undefined || capabilities.effective.includes('rename')),
@@ -471,8 +471,9 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
471
471
  const mutation = mutationRows.get(entry.path);
472
472
  const changeable = mutable(entry);
473
473
  const draggable = changeable && can.rename && !folder;
474
+ const decoration = decorate?.(entry) ?? undefined;
474
475
  const button = _jsxs(Button, { type: "button", variant: selectedPath === entry.path ? 'secondary' : 'ghost', "data-active": selectedPath === entry.path, tabIndex: -1, title: entry.path, disabled: temporary, className: "file-tree-row-button", draggable: draggable || undefined, onClick: () => { focus(entry.path); if (!folder)
475
- onOpen?.(entry.path); }, children: [folder ? _jsx(ChevronRight, { "aria-hidden": "true" }) : list && _jsx("span", { className: "file-tree-chevron-space", "aria-hidden": "true" }), _jsx(Icon, { "aria-hidden": "true" }), _jsxs("span", { className: "file-tree-copy", children: [_jsx("span", { className: "file-tree-name", children: name }), list ? folder ? childCount !== undefined && _jsx("span", { "data-file-meta": true, children: childCount }) : _jsxs("span", { "data-file-meta": true, children: [kind.label, " \u00B7 ", fileSize(blob?.size ?? entry.size, locale), parent && ` · ${parent}`] }) : parent && _jsx("span", { className: "file-tree-parent", "data-file-meta": true, children: parent })] })] });
476
+ onOpen?.(entry.path); }, children: [folder ? _jsx(ChevronRight, { "aria-hidden": "true" }) : list && _jsx("span", { className: "file-tree-chevron-space", "aria-hidden": "true" }), _jsx(Icon, { "aria-hidden": "true" }), _jsxs("span", { className: "file-tree-copy", children: [_jsx("span", { className: "file-tree-name", children: name }), list ? folder ? childCount !== undefined && _jsx("span", { "data-file-meta": true, children: childCount }) : _jsxs("span", { "data-file-meta": true, children: [kind.label, " \u00B7 ", fileSize(blob?.size ?? entry.size, locale), parent && ` · ${parent}`] }) : parent && _jsx("span", { className: "file-tree-parent", "data-file-meta": true, children: parent }), decoration?.detail && _jsx("span", { "data-file-detail": true, children: decoration.detail })] })] });
476
477
  const editing = renaming?.path === entry.path && can.rename;
477
478
  const mutationFailed = mutation?.state.kind === 'settled';
478
479
  const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "data-blob-type": blob?.type, "data-upload-state": status?.kind, "data-mutation-state": mutation ? mutationFailed ? 'failed' : 'pending' : undefined, "data-drop-target": folder && dropTarget === entry.path ? 'true' : undefined, "data-dragging": dragged === entry.path || undefined, "aria-busy": status?.kind === 'pending' || mutation?.state.kind === 'pending' || undefined, ...(folder ? dropHandlers(entry.path) : {}), ...(draggable ? {
@@ -503,7 +504,10 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
503
504
  },
504
505
  // Focus moved somewhere on purpose cancels; a closing menu that drops focus (no related target) does not.
505
506
  onBlur: event => { if (event.relatedTarget)
506
- setRenaming(current => current?.path === entry.path ? undefined : current); } })] }) : folder ? _jsx(CollapsibleTrigger, { render: button }) : button, (status?.kind === 'pending' || mutation?.state.kind === 'pending') && _jsx(LoaderCircle, { "data-upload-spinner": status ? '' : undefined, "data-mutation-spinner": mutation ? '' : undefined, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), mutation && mutationFailed && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-mutation-dismiss": true, "aria-label": labels.dismiss(entry.path), title: labels.dismiss(entry.path), onClick: () => controller.dismiss(mutation.operationId), children: _jsx(X, { "aria-hidden": "true" }) }), !temporary && !status && !editing && mutation?.state.kind !== 'pending' && (!folder || changeable && (can.rename || can.delete)) && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen && !folder ? () => onOpen(entry.path) : undefined, onHistory: history && !folder ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError, onRename: changeable && can.rename && renameable(entry) ? () => startRename(entry.path) : undefined, onMove: changeable && can.rename && Dialog ? () => ask('move', entry.path) : undefined, onDelete: changeable && can.delete ? () => ask('delete', entry.path) : undefined })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), editing && renaming?.error && _jsx("span", { "data-mutation-error": true, role: "alert", children: renaming.error }), mutation && mutationFailed && _jsx("span", { "data-mutation-error": true, children: mutationReason(mutation) }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
507
+ setRenaming(current => current?.path === entry.path ? undefined : current); } })] }) : folder ? _jsx(CollapsibleTrigger, { render: button }) : button, decoration?.badge && !editing && (() => {
508
+ const badge = { tone: decoration.badge.tone, children: decoration.badge.label, role: 'note', 'aria-label': `${name}: ${decoration.badge.label}`, title: decoration.badge.label, 'data-file-badge': '', 'data-tone': decoration.badge.tone };
509
+ return Badge ? _jsx(Badge, { ...badge }) : _jsx("span", { role: badge.role, "aria-label": badge['aria-label'], title: badge.title, "data-file-badge": "", "data-tone": badge.tone, children: badge.children });
510
+ })(), (status?.kind === 'pending' || mutation?.state.kind === 'pending') && _jsx(LoaderCircle, { "data-upload-spinner": status ? '' : undefined, "data-mutation-spinner": mutation ? '' : undefined, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), mutation && mutationFailed && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-mutation-dismiss": true, "aria-label": labels.dismiss(entry.path), title: labels.dismiss(entry.path), onClick: () => controller.dismiss(mutation.operationId), children: _jsx(X, { "aria-hidden": "true" }) }), !temporary && !status && !editing && mutation?.state.kind !== 'pending' && (!folder || changeable && (can.rename || can.delete)) && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen && !folder ? () => onOpen(entry.path) : undefined, onHistory: history && !folder ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError, onRename: changeable && can.rename && renameable(entry) ? () => startRename(entry.path) : undefined, onMove: changeable && can.rename && Dialog ? () => ask('move', entry.path) : undefined, onDelete: changeable && can.delete ? () => ask('delete', entry.path) : undefined })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), editing && renaming?.error && _jsx("span", { "data-mutation-error": true, role: "alert", children: renaming.error }), mutation && mutationFailed && _jsx("span", { "data-mutation-error": true, children: mutationReason(mutation) }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
507
511
  return folder ? _jsx(CollapsibleRoot, { render: item, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); } }, entry.path) : item;
508
512
  };
509
513
  const visibleListing = state.query ? state.search : state.directories.get('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.47",
3
+ "version": "0.4.49",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -150,7 +150,7 @@
150
150
  }
151
151
  },
152
152
  "peerDependencies": {
153
- "@hachej/boring-files": "^0.4.47",
153
+ "@hachej/boring-files": "^0.4.49",
154
154
  "@earendil-works/pi-durable": "~1.4.0",
155
155
  "react": "^19.0.0",
156
156
  "react-dom": "^19.0.0",
@@ -4,15 +4,15 @@
4
4
  "title": "Point and dictate on app data",
5
5
  "description": "Tap explicitly marked JSON app context, dictate once and show the same app agent’s native reply without opening chat. Host-controlled presentation; no feedback report or second controller.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.47",
8
- "@hachej/boring-feedback@^0.4.47",
7
+ "@hachej/boring-ui-kit@^0.4.49",
8
+ "@hachej/boring-feedback@^0.4.49",
9
9
  "@earendil-works/pi-durable@~1.4.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-chat.json"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.49/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.49/r/pi-chat.json"
16
16
  ],
17
17
  "files": [
18
18
  {