@hachej/boring-ui-kit 0.3.2 → 0.4.0
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/README.md +46 -18
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/dist/canvas-editor-native.d.ts +4 -1
- package/dist/canvas-editor-native.js +8 -6
- package/dist/canvas-editor.js +1 -1
- package/dist/canvas-proposals.d.ts +3 -1
- package/dist/canvas-proposals.js +4 -3
- package/dist/experience-document-viewer.d.ts +3 -0
- package/dist/experience-document-viewer.js +6 -5
- package/dist/experience.d.ts +3 -0
- package/dist/experience.js +2 -1
- package/dist/file-tree-view.d.ts +76 -3
- package/dist/file-tree-view.js +172 -39
- package/dist/file-tree.d.ts +3 -0
- package/dist/file-tree.js +17 -2
- package/dist/html-viewer.d.ts +4 -0
- package/dist/html-viewer.js +5 -3
- package/dist/markdown-autosave.d.ts +13 -0
- package/dist/markdown-autosave.js +94 -0
- package/dist/markdown-editor.d.ts +16 -0
- package/dist/markdown-editor.js +81 -17
- package/dist/markdown-extensions.js +9 -1
- package/dist/markdown-proposal-diff.d.ts +2 -1
- package/dist/markdown-proposal-diff.js +3 -2
- package/dist/markdown-safety.js +3 -1
- package/dist/native-chat.js +1 -1
- package/dist/remote-chat.js +5 -1
- package/dist/text-buffer.js +3 -2
- package/dist/text-draft-controls.d.ts +3 -1
- package/dist/text-draft-controls.js +3 -2
- package/dist/viewer-components.d.ts +14 -0
- package/dist/viewer-components.js +1 -0
- package/package.json +4 -4
- package/r/app-shell.json +39 -20
- package/r/button.json +19 -11
- package/r/dock.json +2 -2
- package/r/documents.json +17 -14
- package/r/feedback-preview.json +8 -2
- package/r/feedback.json +46 -325
- package/r/file-tree.json +14 -6
- package/r/html-viewer.json +8 -42
- package/r/markdown-editor.json +38 -87
- package/r/pi-ambient.json +15 -8
- package/r/pi-app.json +29 -18
- package/r/pi-chat.json +88 -69
- package/r/pi-workspace.json +7 -5
- package/r/provider-setup.json +9 -4
- package/r/registry.json +460 -888
- package/r/task-list-viewer.json +11 -21
- package/r/theme.json +23 -4
- package/r/version-history.json +24 -0
- package/r/viewers.json +139 -320
- package/styles/file-tree.css +41 -42
- package/dist/file-tree-shadcn.d.ts +0 -8
- package/dist/file-tree-shadcn.js +0 -14
package/r/button.json
CHANGED
|
@@ -1,28 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
3
|
"name": "button",
|
|
4
|
-
"title": "
|
|
5
|
-
"description": "
|
|
4
|
+
"title": "Block actions",
|
|
5
|
+
"description": "Host action metadata composed with the consumer’s shadcn Button, Toggle and DropdownMenu.",
|
|
6
6
|
"dependencies": [
|
|
7
7
|
"react@^19.0.0",
|
|
8
|
+
"react-dom@^19.0.0",
|
|
8
9
|
"lucide-react@^1.51.0"
|
|
9
10
|
],
|
|
10
11
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/utils.json",
|
|
13
|
+
"dropdown-menu",
|
|
14
|
+
"button",
|
|
15
|
+
"toggle",
|
|
16
|
+
"input",
|
|
17
|
+
"textarea",
|
|
18
|
+
"card",
|
|
19
|
+
"separator"
|
|
12
20
|
],
|
|
13
21
|
"files": [
|
|
14
|
-
{
|
|
15
|
-
"path": "registry/button/button.tsx",
|
|
16
|
-
"content": "import type { ButtonHTMLAttributes, ReactNode, Ref } from 'react';\nimport { cn } from '../utils/utils';\n\nconst variants = {\n default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n outline: 'border border-border bg-background hover:bg-muted',\n ghost: 'hover:bg-muted hover:text-foreground',\n /** A ghost button whose resting text is muted (viewer bars). */\n quiet: 'text-muted-foreground hover:bg-muted hover:text-foreground',\n destructive: 'bg-destructive text-white hover:bg-destructive/90',\n} as const;\n// Touch targets: on a phone or any coarse pointer every button is at least 40px (icon buttons 44px), whatever its desktop size.\n// `bar` and `icon-bar` are the compact viewer-bar sizes: 44px on touch, with the tighter radius of a toolbar.\nconst sizes = {\n sm: 'h-7 gap-1 px-2.5 text-xs max-sm:h-10 pointer-coarse:h-10',\n md: 'h-9 gap-1.5 px-3.5 text-sm max-sm:h-11 pointer-coarse:h-11',\n icon: 'size-9 max-sm:size-11 pointer-coarse:size-11',\n 'icon-sm': 'size-7 max-sm:size-10 pointer-coarse:size-10',\n bar: 'h-8 gap-1.5 rounded-md px-3 text-xs max-sm:h-11 pointer-coarse:h-11',\n 'icon-bar': 'size-8 rounded-md max-sm:size-11 pointer-coarse:size-11',\n} as const;\n\nexport interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n readonly variant?: keyof typeof variants;\n readonly size?: keyof typeof sizes;\n readonly ref?: Ref<HTMLButtonElement>;\n}\n\n/** shadcn-style button shared by every item. Always `type=\"button\"` unless a form submit is requested. */\nexport function Button({ className, variant = 'ghost', size = 'md', type = 'button', ...props }: ButtonProps) {\n return <button type={type} className={cn(\n 'inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg font-medium whitespace-nowrap transition-colors outline-none',\n 'focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 focus-visible:ring-offset-background',\n 'disabled:pointer-events-none disabled:opacity-45 motion-reduce:transition-none',\n variants[variant], sizes[size], className)} {...props} />;\n}\n\n/** A bar button: an icon with an accessible name and a tooltip (quiet and bar-sized unless told otherwise). */\nexport function IconButton({ label, children, variant = 'quiet', size = 'icon-bar', ...props }: { readonly label: string; readonly children: ReactNode } & Omit<ButtonProps, 'children'>) {\n return <Button variant={variant} size={size} aria-label={label} title={label} {...props}>{children}</Button>;\n}\n",
|
|
17
|
-
"type": "registry:ui",
|
|
18
|
-
"target": "components/button/button.tsx"
|
|
19
|
-
},
|
|
20
22
|
{
|
|
21
23
|
"path": "registry/button/actions.tsx",
|
|
22
|
-
"content": "'use client';\n\nimport {
|
|
24
|
+
"content": "'use client';\n\nimport { Fragment, useCallback, useRef } from 'react';\nimport type { ComponentProps, ComponentType, ReactNode, RefObject } from 'react';\nimport { EllipsisIcon } from 'lucide-react';\nimport { DropdownMenu, DropdownMenuTrigger, DropdownMenuPortal, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut } from '@/components/ui/dropdown-menu';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { cn } from '../utils/utils';\n\nexport type BlockIcon = ComponentType<{ readonly className?: string | undefined; readonly 'aria-hidden'?: boolean | 'true' | 'false' | undefined }>;\n\n/** Host actions in a header, reply footer or menu. The host retains all execution policy. */\nexport interface BlockAction {\n readonly id: string;\n readonly label: string;\n readonly icon?: BlockIcon | undefined;\n readonly onSelect: () => void;\n readonly placement?: 'header' | 'menu' | undefined;\n readonly disabled?: boolean | undefined;\n readonly pressed?: boolean | undefined;\n readonly destructive?: boolean | undefined;\n}\n\nexport interface MenuItem {\n readonly id: string;\n readonly label: string;\n readonly description?: string;\n readonly icon?: ReactNode;\n readonly hint?: string;\n readonly shortcut?: string;\n readonly checked?: boolean;\n readonly disabled?: boolean;\n readonly destructive?: boolean;\n readonly separatorBefore?: boolean;\n readonly group?: string;\n readonly testId?: string;\n readonly onSelect: () => void;\n}\n\ntype ActionIconSize = Extract<ComponentProps<typeof Button>['size'], 'icon' | 'icon-sm'>;\n\nexport function ActionMenuItemContent({ item, testId }: { readonly item: MenuItem; readonly testId: string }) {\n return <>\n {item.icon && <span className=\"text-muted-foreground\">{item.icon}</span>}\n <span className=\"min-w-0 flex-1\"><span className=\"block truncate\">{item.label}</span>{item.description && <span data-testid={`${testId}-description`} className=\"block text-xs text-muted-foreground\">{item.description}</span>}</span>\n {item.hint && <span data-testid={`${testId}-hint`} className=\"shrink-0 text-xs text-muted-foreground\">{item.hint}</span>}\n {item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>}\n </>;\n}\n\n/**\n * Portal target and trigger ref for a menu that must open in its trigger's own document (a viewer in a pop-out window).\n * Put `ref` on the trigger button and `container` on `ActionMenuContent`.\n */\nexport function useMenuPortal() {\n const container = useRef<HTMLElement | null>(null);\n const ref = useCallback((node: HTMLElement | null) => { container.current = node?.ownerDocument.body ?? null; }, []);\n return { container, ref };\n}\n\n/** Maps host menu items to the consumer's shadcn dropdown items inside a `DropdownMenu`; the primitives own focus, positioning and dismissal. */\nexport function ActionMenuContent({ label, testId, items, container, align = 'end', children }: { readonly label: string; readonly testId: string; readonly items: readonly MenuItem[]; readonly container?: RefObject<HTMLElement | null>; readonly align?: 'start' | 'end'; readonly children?: ReactNode }) {\n const groups: MenuItem[][] = [];\n for (const item of items) {\n const last = groups[groups.length - 1];\n if (!last || item.separatorBefore || last[0]?.group !== item.group) groups.push([item]);\n else last.push(item);\n }\n const rows = groups.map((group, index) => <Fragment key={group[0]!.id}>\n {index > 0 && <DropdownMenuSeparator />}\n <DropdownMenuGroup>\n {group[0]?.group && <DropdownMenuLabel>{group[0].group}</DropdownMenuLabel>}\n {group.map(item => {\n const common = { disabled: item.disabled, 'data-testid': item.testId ?? `${testId}-${item.id}`, 'data-item': item.id, onClick: item.onSelect, className: 'min-h-8 max-sm:min-h-10 pointer-coarse:min-h-10' };\n const content = <ActionMenuItemContent item={item} testId={testId} />;\n return item.checked !== undefined\n ? <DropdownMenuRadioItem key={item.id} value={item.id} {...common}>{content}</DropdownMenuRadioItem>\n : <DropdownMenuItem key={item.id} variant={item.destructive ? 'destructive' : 'default'} {...common}>{content}</DropdownMenuItem>;\n })}\n </DropdownMenuGroup>\n </Fragment>);\n return <DropdownMenuPortal {...(container ? { container } : {})}>\n <DropdownMenuContent align={align} aria-label={label} data-testid={`${testId}-list`} className=\"w-64 max-w-[calc(100vw-1rem)]\">\n {children}\n {items.some(item => item.checked !== undefined) ? <DropdownMenuRadioGroup value={items.find(item => item.checked)?.id ?? ''}>{rows}</DropdownMenuRadioGroup> : rows}\n </DropdownMenuContent>\n </DropdownMenuPortal>;\n}\n\nexport function actionMenuItem(action: BlockAction, testId: string): MenuItem {\n const Icon = action.icon;\n return { id: action.id, label: action.label, ...(Icon ? { icon: <Icon data-icon=\"inline-start\" aria-hidden=\"true\" /> } : {}), ...(action.disabled ? { disabled: true } : {}), ...(action.destructive ? { destructive: true } : {}), testId: `${testId}-${action.id}`, onSelect: action.onSelect };\n}\n\nexport const headerActions = (actions: readonly BlockAction[] | undefined): readonly BlockAction[] => (actions ?? []).filter(action => action.placement !== 'menu');\nexport const menuActions = (actions: readonly BlockAction[] | undefined): readonly BlockAction[] => (actions ?? []).filter(action => action.placement === 'menu');\n\nexport function ActionButton({ action, testId, size = 'icon-sm', className, iconOnly = false }: { readonly action: BlockAction; readonly testId: string; readonly size?: ActionIconSize; readonly className?: string; readonly iconOnly?: boolean }) {\n const Icon = action.icon ?? (iconOnly ? EllipsisIcon : undefined);\n const content = Icon ? <Icon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <span className=\"truncate\">{action.label}</span>;\n const layout = cn(!Icon && 'max-w-40 min-w-0', className);\n if (action.pressed !== undefined) return <Toggle size={size === 'icon' ? 'default' : 'sm'} aria-label={action.label} title={action.label} data-testid={`${testId}-${action.id}`} data-action={action.id} disabled={action.disabled} pressed={action.pressed} onPressedChange={() => action.onSelect()} className={layout}>{content}</Toggle>;\n return <Button size={Icon ? size : 'sm'} variant=\"ghost\" aria-label={action.label} title={action.label} data-testid={`${testId}-${action.id}`} data-action={action.id} disabled={action.disabled} onClick={action.onSelect} className={layout}>{content}</Button>;\n}\n\nexport function BlockActions({ actions, testId, menuLabel, size = 'icon-sm', className, iconOnly = false }: {\n readonly actions: readonly BlockAction[] | undefined;\n readonly testId: string;\n readonly menuLabel: string;\n readonly size?: ActionIconSize;\n readonly className?: string;\n readonly iconOnly?: boolean;\n}) {\n const portal = useMenuPortal();\n const buttons = headerActions(actions), menu = menuActions(actions);\n if (!buttons.length && !menu.length) return null;\n const moreId = `${testId}-more`;\n return <div data-boring=\"block-actions\" data-actions={testId} className={cn('flex shrink-0 items-center gap-0.5', className)}>\n {buttons.map(action => <ActionButton key={action.id} action={action} testId={testId} size={size} iconOnly={iconOnly} />)}\n {menu.length > 0 && <DropdownMenu>\n <DropdownMenuTrigger render={<Button ref={portal.ref} variant=\"ghost\" aria-label={menuLabel} title={menuLabel} size={size} data-testid={moreId}><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>} />\n <ActionMenuContent label={menuLabel} testId={moreId} container={portal.container} items={menu.map(action => actionMenuItem(action, testId))} />\n </DropdownMenu>}\n </div>;\n}\n",
|
|
23
25
|
"type": "registry:ui",
|
|
24
26
|
"target": "components/button/actions.tsx"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"path": "registry/button/editor-controls.tsx",
|
|
30
|
+
"content": "'use client';\n\nimport type { ComponentPropsWithRef, PropsWithChildren } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Input } from '@/components/ui/input';\nimport { Textarea } from '@/components/ui/textarea';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\n\nfunction EditorAction({ 'aria-pressed': pressed, value, ...props }: ComponentPropsWithRef<'button'>) {\n return pressed === undefined\n ? <Button variant=\"ghost\" size=\"sm\" value={value} {...props} />\n : <Toggle size=\"sm\" pressed={pressed === true || pressed === 'true'} value={value === undefined ? undefined : String(value)} {...props} />;\n}\n\nfunction EditorBubble({ children }: PropsWithChildren) {\n return <Card size=\"sm\"><CardContent className=\"flex items-center gap-1\">{children}</CardContent></Card>;\n}\n\n/** Maps native editor action/input props to the consumer's unchanged shadcn controls. */\nexport const editorControls = { button: EditorAction, input: Input, textarea: Textarea, bubble: EditorBubble, separator: Separator };\n",
|
|
31
|
+
"type": "registry:component",
|
|
32
|
+
"target": "components/button/editor-controls.tsx"
|
|
25
33
|
}
|
|
26
34
|
],
|
|
27
|
-
"type": "registry:
|
|
35
|
+
"type": "registry:component"
|
|
28
36
|
}
|
package/r/dock.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"react@^19.0.0"
|
|
8
8
|
],
|
|
9
9
|
"registryDependencies": [
|
|
10
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/utils.json"
|
|
11
11
|
],
|
|
12
12
|
"files": [
|
|
13
13
|
{
|
|
14
14
|
"path": "registry/dock/dock.tsx",
|
|
15
|
-
"content": "'use client';\n\nimport { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport { cn } from '../utils/utils';\n\n/**\n * Composable docks: `DockLayout` is a row holding one `DockMain` (the page's own content, an app for example) and any number of\n * `Dock`s on its left or right (a chat, a list of sessions, a file explorer, an artifact panel). Each dock is resizable from its inner\n * edge and has its own placement: docked, closed, floating (the content renders its own floating surface, for example `AmbientChat`) or,\n * on a narrow layout, a full-screen sheet. `useDock(id)` reaches a dock from anywhere inside the layout, so a button in a chat header can\n * open the files or detach the chat. Layout only: what the docks show is the host's.\n */\n\n/** What a dock is doing now. `floating` keeps the content mounted in a zero-width slot so it can render its own floating surface. */\nexport type DockPlacement = 'docked' | 'floating' | 'sheet' | 'closed';\n\n/** What a dock and the controls around it can do. */\nexport interface DockApi {\n readonly id: string;\n readonly placement: DockPlacement;\n readonly open: boolean;\n readonly floating: boolean;\n /** Whether the layout is wide enough to dock it (false below `narrowBelow`). */\n readonly canDock: boolean;\n readonly width: number;\n readonly setOpen: (next: boolean) => void;\n readonly toggle: () => void;\n /** Float it (only for a `floatable` dock). Opens it if closed. */\n readonly float: () => void;\n /** Put it back beside the main content at its last docked width. Opens it if closed. */\n readonly dock: () => void;\n /** Set the docked width (clamped and remembered like a drag), for example to widen a dock while it shows a document. */\n readonly setWidth: (width: number) => void;\n}\n\nexport interface DockLayoutProps {\n readonly children: ReactNode;\n /** Prefix of the remembered widths, placements and open states (`<storageKey>.<dock id>.width`, …). */\n readonly storageKey?: string;\n /** `session` (default) remembers the layout for the browser tab; `local` across visits. */\n readonly persist?: 'session' | 'local';\n /** The main content is never narrower than this while docks are beside it. */\n readonly minMain?: number;\n readonly className?: string;\n}\n\nexport interface DockProps {\n readonly id: string;\n readonly side: 'left' | 'right';\n /** The dock's content, or a function of its api (to render a floating surface while `floating`, or a close button). Stays mounted in every placement. */\n readonly children: ReactNode | ((api: DockApi) => ReactNode);\n readonly defaultWidth?: number;\n readonly minWidth?: number;\n /** Opt in to floating: dragging below `dragBelow`, Alt+Arrow towards the edge and `api.float` float it, and it floats when the layout is narrow. */\n readonly floatable?: boolean;\n /**\n * Dragging the divider until the dock would be narrower than this shows a hint and releasing floats it (`floatable`) or closes it.\n * Absent: the dock stops at `minWidth`.\n */\n readonly dragBelow?: number;\n /** Below this layout width the dock cannot be docked: it floats (`floatable`), becomes a full-screen sheet while open (`narrow: 'sheet'`) or is hidden. */\n readonly narrowBelow?: number;\n readonly narrow?: 'float' | 'sheet' | 'hide';\n /** Controlled open state (with `onOpenChange`); uncontrolled from `defaultOpen` (true) and remembered otherwise. */\n readonly open?: boolean;\n readonly defaultOpen?: boolean;\n readonly onOpenChange?: (open: boolean) => void;\n /** Floating at first, before the person docks it. */\n readonly defaultFloating?: boolean;\n /**\n * While docked, the dock is the containing block of its content's fixed overlays (a full-screen sheet, a drawer, a viewer's full screen):\n * they cover the dock, not the page. Floating and sheet placements are unaffected.\n */\n readonly contained?: boolean;\n readonly onPlacementChange?: (placement: DockPlacement) => void;\n /** The region's and the divider's accessible names, and the hint shown while a drag would float or close the dock. */\n readonly labels?: { readonly region?: string | undefined; readonly resize?: string | undefined; readonly floatHint?: string | undefined; readonly closeHint?: string | undefined } | undefined;\n readonly className?: string;\n}\n\nexport interface DockMainProps { readonly children: ReactNode; readonly className?: string }\n\ninterface DockRecord { readonly width?: number | undefined; readonly floating?: boolean | undefined; readonly open?: boolean | undefined }\ninterface LayoutContext {\n readonly size: number;\n readonly minMain: number;\n readonly records: Readonly<Record<string, DockRecord>>;\n readonly apis: Readonly<Record<string, DockApi>>;\n readonly update: (id: string, patch: DockRecord) => void;\n readonly publish: (id: string, api: DockApi | undefined) => void;\n readonly read: (id: string) => DockRecord;\n readonly occupied: (except: string) => number;\n}\nconst Layout = createContext<LayoutContext | null>(null);\nconst STEP = 24, BIG_STEP = 96;\n\nfunction store(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\n\n/** The row of docks and main content. Measures its own width, so it works inside any container. */\nexport function DockLayout({ children, storageKey = 'boring.dock', persist = 'session', minMain = 360, className }: DockLayoutProps) {\n const root = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(0);\n const [records, setRecords] = useState<Record<string, DockRecord>>({});\n const [apis, setApis] = useState<Record<string, DockApi>>({});\n const widths = useRef<Record<string, number>>({});\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setSize(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const read = useCallback((id: string): DockRecord => {\n const storage = store(persist);\n const get = (name: string) => { try { return storage?.getItem(`${storageKey}.${id}.${name}`) ?? null; } catch { return null; } };\n const width = Number(get('width')), floating = get('floating'), open = get('open');\n return { width: Number.isFinite(width) && width > 0 ? width : undefined, floating: floating === null ? undefined : floating === '1', open: open === null ? undefined : open === '1' };\n }, [persist, storageKey]);\n const update = useCallback((id: string, patch: DockRecord) => {\n setRecords(current => ({ ...current, [id]: { ...current[id], ...patch } }));\n const storage = store(persist);\n for (const [name, value] of Object.entries(patch)) {\n if (value === undefined) continue;\n try { storage?.setItem(`${storageKey}.${id}.${name}`, typeof value === 'boolean' ? (value ? '1' : '0') : String(Math.round(value))); } catch { /* the layout is a convenience */ }\n }\n }, [persist, storageKey]);\n const publish = useCallback((id: string, api: DockApi | undefined) => {\n widths.current = { ...widths.current, [id]: api?.placement === 'docked' ? api.width : 0 };\n setApis(current => {\n if (api === undefined) { const { [id]: _removed, ...rest } = current; return rest; }\n const previous = current[id];\n return previous && sameApi(previous, api) ? current : { ...current, [id]: api };\n });\n }, []);\n const occupied = useCallback((except: string) => Object.entries(widths.current).reduce((total, [id, width]) => id === except ? total : total + width, 0), []);\n const value = useMemo(() => ({ size, minMain, records, apis, update, publish, read, occupied }), [size, minMain, records, apis, update, publish, read, occupied]);\n return <Layout.Provider value={value}>\n <div ref={root} data-boring=\"dock-layout\" className={cn('relative flex h-full min-h-0 min-w-0 flex-1', className)}>{children}</div>\n </Layout.Provider>;\n}\n\nconst sameApi = (a: DockApi, b: DockApi) => a.placement === b.placement && a.open === b.open && a.width === b.width && a.canDock === b.canDock;\n\n/** The page's own content between the docks; it takes the remaining width. */\nexport function DockMain({ children, className }: DockMainProps) {\n return <main data-boring=\"dock-main\" className={cn('relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden', className)}>{children}</main>;\n}\n\n/** A dock's api from anywhere inside the `DockLayout` (undefined until that dock has rendered). */\nexport function useDock(id: string): DockApi | undefined {\n const layout = useContext(Layout);\n if (!layout) throw new Error('useDock must be used inside a DockLayout');\n return layout.apis[id];\n}\n\n/** One dock beside the main content. Order the children as they should appear: left docks, `DockMain`, right docks. */\nexport function Dock({ id, side, children, defaultWidth = 400, minWidth = 280, floatable = false, dragBelow, narrowBelow, narrow = floatable ? 'float' : 'hide',\n open: controlledOpen, defaultOpen = true, onOpenChange, defaultFloating = false, contained = false, onPlacementChange, labels, className }: DockProps) {\n const layout = useContext(Layout);\n if (!layout) throw new Error('Dock must be used inside a DockLayout');\n const { size, minMain, update, publish, occupied } = layout;\n const [initial] = useState(() => layout.read(id));\n const record = { ...initial, ...layout.records[id] };\n const [dragging, setDragging] = useState(false);\n const [hint, setHint] = useState(false);\n const section = useRef<HTMLElement>(null);\n const controlled = controlledOpen !== undefined;\n const open = controlled ? controlledOpen : record.open ?? defaultOpen;\n const narrowNow = narrowBelow !== undefined && size > 0 && size < narrowBelow;\n const floatingChosen = floatable && (record.floating ?? defaultFloating);\n const placement: DockPlacement = !open ? 'closed'\n : narrowNow ? (narrow === 'float' && floatable ? 'floating' : narrow === 'sheet' ? 'sheet' : 'closed')\n : floatingChosen ? 'floating' : 'docked';\n const max = Math.max(minWidth, size - minMain - occupied(id));\n const width = Math.round(Math.min(Math.max(record.width ?? defaultWidth, minWidth), max));\n\n const setOpen = useCallback((next: boolean) => { if (!controlled) update(id, { open: next }); onOpenChange?.(next); }, [controlled, id, onOpenChange, update]);\n const setFloating = useCallback((next: boolean) => { if (floatable) update(id, { floating: next }); }, [floatable, id, update]);\n const api: DockApi = {\n id, placement, open, floating: placement === 'floating', canDock: !narrowNow, width,\n setOpen, toggle: () => setOpen(!open),\n float: () => { setFloating(true); if (!open) setOpen(true); },\n dock: () => { setFloating(false); if (!open) setOpen(true); },\n setWidth: (next: number) => resize(next),\n };\n const latest = useRef(api); latest.current = api;\n // What `useDock` hands out: the current state, with actions that always reach this render's dock.\n const actions = useMemo(() => ({ setOpen: (next: boolean) => latest.current.setOpen(next), toggle: () => latest.current.toggle(), float: () => latest.current.float(), dock: () => latest.current.dock(), setWidth: (next: number) => latest.current.setWidth(next) }), []);\n useLayoutEffect(() => { publish(id, { ...latest.current, ...actions }); });\n useLayoutEffect(() => () => publish(id, undefined), [id, publish]);\n const reported = useRef(placement);\n useLayoutEffect(() => { if (reported.current !== placement) { reported.current = placement; onPlacementChange?.(placement); } }, [placement, onPlacementChange]);\n\n const resize = (next: number) => update(id, { width: Math.min(Math.max(next, minWidth), max) });\n // Past `dragBelow` the dock keeps its last width; releasing there floats or closes it.\n const drag = (event: PointerEvent<HTMLElement>) => {\n if (!dragging) return;\n const box = section.current?.getBoundingClientRect();\n if (!box) return;\n const target = side === 'left' ? event.clientX - box.left : box.right - event.clientX;\n const below = dragBelow !== undefined && target < dragBelow;\n setHint(below);\n if (!below) resize(target);\n };\n const leave = () => { if (floatable) api.float(); else setOpen(false); };\n const release = (event: PointerEvent<HTMLElement>) => { event.currentTarget.releasePointerCapture?.(event.pointerId); setDragging(false); if (hint) leave(); setHint(false); };\n const keys = (event: KeyboardEvent<HTMLElement>) => {\n const outward = side === 'left' ? 'ArrowLeft' : 'ArrowRight', inward = side === 'left' ? 'ArrowRight' : 'ArrowLeft';\n if (event.altKey && event.key === outward && (floatable || dragBelow !== undefined)) { event.preventDefault(); leave(); return; }\n const step = event.shiftKey ? BIG_STEP : STEP;\n const next = event.key === inward ? width + step : event.key === outward ? width - step : event.key === 'Home' ? minWidth : event.key === 'End' ? max : undefined;\n if (next === undefined) return;\n event.preventDefault(); resize(next);\n };\n\n const docked = placement === 'docked';\n const divider = docked && <div role=\"separator\" tabIndex={0} aria-orientation=\"vertical\" aria-label={labels?.resize ?? 'Resize panel'} aria-valuemin={minWidth} aria-valuemax={max} aria-valuenow={width}\n data-testid={`dock-divider-${id}`} data-dragging={dragging ? 'true' : undefined}\n // No text selection or native drag from the divider: after a double-click reset, the next press must still resize.\n onPointerDown={event => { event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture?.(event.pointerId); setDragging(true); }} onPointerMove={drag}\n onDragStart={event => event.preventDefault()}\n onPointerUp={release} onPointerCancel={() => { setDragging(false); setHint(false); }} onKeyDown={keys} onDoubleClick={() => resize(defaultWidth)}\n className=\"group relative z-10 -mx-1 w-2 shrink-0 cursor-col-resize touch-none select-none outline-none\">\n <span aria-hidden=\"true\" className={cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-[width,background-color] group-hover:w-0.5 group-hover:bg-ring/70 group-focus-visible:w-0.5 group-focus-visible:bg-ring motion-reduce:transition-none', dragging && 'w-0.5 bg-ring')} />\n </div>;\n const hidden = placement === 'closed';\n const panel = <section ref={section} data-boring=\"dock\" data-dock={id} data-side={side} data-placement={placement} aria-label={labels?.region}\n {...(hidden ? { hidden: true, inert: true } : {})}\n style={docked ? { width } : undefined}\n className={cn('relative flex min-h-0 min-w-0 flex-col', docked && 'shrink-0 overflow-hidden bg-background text-foreground', docked && contained && '[contain:layout]',\n placement === 'floating' && 'w-0 flex-none overflow-visible', placement === 'sheet' && 'fixed inset-0 z-50 bg-background text-foreground', hidden && 'hidden',\n dragging && '[&_iframe]:pointer-events-none select-none', className)}>\n {typeof children === 'function' ? children(api) : children}\n {hint && <div role=\"status\" data-testid={`dock-hint-${id}`} className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/75 p-4 backdrop-blur-[2px]\">\n <span className=\"rounded-full border border-border bg-popover px-4 py-2 text-center text-sm font-medium text-popover-foreground shadow-lg\">\n {floatable ? labels?.floatHint ?? 'Release to float' : labels?.closeHint ?? 'Release to close'}</span>\n </div>}\n </section>;\n return side === 'left' ? <>{panel}{divider}</> : <>{divider}{panel}</>;\n}\n",
|
|
15
|
+
"content": "'use client';\n\nimport { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport { cn } from '../utils/utils';\n\n/**\n * Composable docks: `DockLayout` is a row holding one `DockMain` (the page's own content, an app for example) and any number of\n * `Dock`s on its left or right (a chat, a list of sessions, a file explorer, an artifact panel). Each dock is resizable from its inner\n * edge and has its own placement: docked, closed, floating (the content renders its own floating surface, for example `AmbientChat`) or,\n * on a narrow layout, a full-screen sheet. `useDock(id)` reaches a dock from anywhere inside the layout, so a button in a chat header can\n * open the files or detach the chat. Layout only: what the docks show is the host's.\n */\n\n/** What a dock is doing now. `floating` keeps the content mounted in a zero-width slot so it can render its own floating surface. */\nexport type DockPlacement = 'page' | 'docked' | 'floating' | 'sheet' | 'closed';\n\n/** What a dock and the controls around it can do. */\nexport interface DockApi {\n readonly id: string;\n readonly placement: DockPlacement;\n readonly open: boolean;\n readonly floating: boolean;\n /** Whether the layout is wide enough to dock it (false below `narrowBelow`). */\n readonly canDock: boolean;\n readonly width: number;\n /** Page fills the main area; docked sits beside it; floating uses the host's ambient surface. Opens the dock. */\n readonly setPlacement: (next: 'page' | 'docked' | 'floating') => void;\n readonly setOpen: (next: boolean) => void;\n readonly toggle: () => void;\n /** Float it (only for a `floatable` dock). Opens it if closed. */\n readonly float: () => void;\n /** Put it back beside the main content at its last docked width. Opens it if closed. */\n readonly dock: () => void;\n /** Set the docked width (clamped and remembered like a drag), for example to widen a dock while it shows a document. */\n readonly setWidth: (width: number) => void;\n}\n\nexport interface DockLayoutProps {\n readonly children: ReactNode;\n /** Prefix of the remembered widths, placements and open states (`<storageKey>.<dock id>.width`, …). */\n readonly storageKey?: string;\n /** `session` (default) remembers the layout for the browser tab; `local` across visits. */\n readonly persist?: 'session' | 'local';\n /** The main content is never narrower than this while docks are beside it. */\n readonly minMain?: number;\n readonly className?: string;\n}\n\nexport interface DockProps {\n readonly id: string;\n readonly side: 'left' | 'right';\n /** The dock's content, or a function of its api (to render a floating surface while `floating`, or a close button). Stays mounted in every placement. */\n readonly children: ReactNode | ((api: DockApi) => ReactNode);\n readonly defaultWidth?: number;\n readonly minWidth?: number;\n /** Opt in to floating: dragging below `dragBelow`, Alt+Arrow towards the edge and `api.float` float it, and it floats when the layout is narrow. */\n readonly floatable?: boolean;\n /**\n * Dragging the divider until the dock would be narrower than this shows a hint and releasing floats it (`floatable`) or closes it.\n * Absent: the dock stops at `minWidth`.\n */\n readonly dragBelow?: number;\n /** Below this layout width the dock cannot be docked: it floats (`floatable`), becomes a full-screen sheet while open (`narrow: 'sheet'`) or is hidden. */\n readonly narrowBelow?: number;\n readonly narrow?: 'float' | 'sheet' | 'hide';\n /** Controlled open state (with `onOpenChange`); uncontrolled from `defaultOpen` (true) and remembered otherwise. */\n readonly open?: boolean;\n readonly defaultOpen?: boolean;\n readonly onOpenChange?: (open: boolean) => void;\n /** Floating at first, before the person docks it. */\n readonly defaultFloating?: boolean;\n /** Initial placement. Takes precedence over defaultFloating; remembered choices take precedence over both. */\n readonly defaultPlacement?: 'page' | 'docked' | 'floating';\n /**\n * While docked, the dock is the containing block of its content's fixed overlays (a full-screen sheet, a drawer, a viewer's full screen):\n * they cover the dock, not the page. Floating and sheet placements are unaffected.\n */\n readonly contained?: boolean;\n readonly onPlacementChange?: (placement: DockPlacement) => void;\n /** The region's and the divider's accessible names, and the hint shown while a drag would float or close the dock. */\n readonly labels?: { readonly region?: string | undefined; readonly resize?: string | undefined; readonly floatHint?: string | undefined; readonly closeHint?: string | undefined } | undefined;\n readonly className?: string;\n}\n\nexport interface DockMainProps { readonly children: ReactNode; readonly className?: string }\n\ninterface DockRecord { readonly width?: number | undefined; readonly placement?: 'page' | 'docked' | 'floating' | undefined; readonly floating?: boolean | undefined; readonly open?: boolean | undefined }\ninterface LayoutContext {\n readonly size: number;\n readonly minMain: number;\n readonly records: Readonly<Record<string, DockRecord>>;\n readonly apis: Readonly<Record<string, DockApi>>;\n readonly update: (id: string, patch: DockRecord) => void;\n readonly publish: (id: string, api: DockApi | undefined) => void;\n readonly read: (id: string) => DockRecord;\n readonly occupied: (except: string) => number;\n}\nconst Layout = createContext<LayoutContext | null>(null);\nconst STEP = 24, BIG_STEP = 96;\n\nfunction store(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\n\n/** The row of docks and main content. Measures its own width, so it works inside any container. */\nexport function DockLayout({ children, storageKey = 'boring.dock', persist = 'session', minMain = 360, className }: DockLayoutProps) {\n const root = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(0);\n const [records, setRecords] = useState<Record<string, DockRecord>>({});\n const [apis, setApis] = useState<Record<string, DockApi>>({});\n const widths = useRef<Record<string, number>>({});\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setSize(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const read = useCallback((id: string): DockRecord => {\n const storage = store(persist);\n const get = (name: string) => { try { return storage?.getItem(`${storageKey}.${id}.${name}`) ?? null; } catch { return null; } };\n const width = Number(get('width')), floating = get('floating'), open = get('open'), placement = get('placement');\n return { width: Number.isFinite(width) && width > 0 ? width : undefined, placement: placement === 'page' || placement === 'docked' || placement === 'floating' ? placement : undefined,\n floating: floating === null ? undefined : floating === '1', open: open === null ? undefined : open === '1' };\n }, [persist, storageKey]);\n const update = useCallback((id: string, patch: DockRecord) => {\n setRecords(current => ({ ...current, [id]: { ...current[id], ...patch } }));\n const storage = store(persist);\n for (const [name, value] of Object.entries(patch)) {\n if (value === undefined) continue;\n try { storage?.setItem(`${storageKey}.${id}.${name}`, typeof value === 'boolean' ? (value ? '1' : '0') : typeof value === 'number' ? String(Math.round(value)) : value); } catch { /* the layout is a convenience */ }\n }\n }, [persist, storageKey]);\n const publish = useCallback((id: string, api: DockApi | undefined) => {\n widths.current = { ...widths.current, [id]: api?.placement === 'docked' ? api.width : 0 };\n setApis(current => {\n if (api === undefined) { const { [id]: _removed, ...rest } = current; return rest; }\n const previous = current[id];\n return previous && sameApi(previous, api) ? current : { ...current, [id]: api };\n });\n }, []);\n const occupied = useCallback((except: string) => Object.entries(widths.current).reduce((total, [id, width]) => id === except ? total : total + width, 0), []);\n const value = useMemo(() => ({ size, minMain, records, apis, update, publish, read, occupied }), [size, minMain, records, apis, update, publish, read, occupied]);\n return <Layout.Provider value={value}>\n <div ref={root} data-boring=\"dock-layout\" className={cn('relative flex h-full min-h-0 min-w-0 flex-1', className)}>{children}</div>\n </Layout.Provider>;\n}\n\nconst sameApi = (a: DockApi, b: DockApi) => a.placement === b.placement && a.open === b.open && a.width === b.width && a.canDock === b.canDock;\n\n/** The page's own content between the docks; it takes the remaining width. */\nexport function DockMain({ children, className }: DockMainProps) {\n const layout = useContext(Layout);\n const replaced = Object.values(layout?.apis ?? {}).some(api => api.placement === 'page');\n return <main data-boring=\"dock-main\" hidden={replaced} inert={replaced} className={cn('relative min-h-0 min-w-0 flex-1 flex-col overflow-hidden', replaced ? 'hidden' : 'flex', className)}>{children}</main>;\n}\n\n/** A dock's api from anywhere inside the `DockLayout` (undefined until that dock has rendered). */\nexport function useDock(id: string): DockApi | undefined {\n const layout = useContext(Layout);\n if (!layout) throw new Error('useDock must be used inside a DockLayout');\n return layout.apis[id];\n}\n\n/** One dock beside the main content. Order the children as they should appear: left docks, `DockMain`, right docks. */\nexport function Dock({ id, side, children, defaultWidth = 400, minWidth = 280, floatable = false, dragBelow, narrowBelow, narrow = floatable ? 'float' : 'hide',\n open: controlledOpen, defaultOpen = true, onOpenChange, defaultFloating = false, defaultPlacement, contained = false, onPlacementChange, labels, className }: DockProps) {\n const layout = useContext(Layout);\n if (!layout) throw new Error('Dock must be used inside a DockLayout');\n const { size, minMain, update, publish, occupied } = layout;\n const [initial] = useState(() => layout.read(id));\n const record = { ...initial, ...layout.records[id] };\n const [dragging, setDragging] = useState(false);\n const [hint, setHint] = useState(false);\n const section = useRef<HTMLElement>(null);\n const controlled = controlledOpen !== undefined;\n const open = controlled ? controlledOpen : record.open ?? defaultOpen;\n const narrowNow = narrowBelow !== undefined && size > 0 && size < narrowBelow;\n const chosen = record.placement ?? (record.floating === undefined ? defaultPlacement ?? (defaultFloating ? 'floating' : 'docked') : record.floating ? 'floating' : 'docked');\n const placement: DockPlacement = !open ? 'closed'\n : chosen === 'page' ? 'page'\n : narrowNow ? (narrow === 'float' && floatable ? 'floating' : narrow === 'sheet' ? 'sheet' : 'closed')\n : chosen === 'floating' && floatable ? 'floating' : 'docked';\n const max = Math.max(minWidth, size - minMain - occupied(id));\n const width = Math.round(Math.min(Math.max(record.width ?? defaultWidth, minWidth), max));\n\n const setOpen = useCallback((next: boolean) => { if (!controlled) update(id, { open: next }); onOpenChange?.(next); }, [controlled, id, onOpenChange, update]);\n const setPlacement = useCallback((next: 'page' | 'docked' | 'floating') => {\n if (next === 'floating' && !floatable) return;\n update(id, { placement: next, floating: next === 'floating' });\n if (!open) setOpen(true);\n }, [floatable, id, open, setOpen, update]);\n const api: DockApi = {\n id, placement, open, floating: placement === 'floating', canDock: !narrowNow, width,\n setPlacement, setOpen, toggle: () => setOpen(!open),\n float: () => setPlacement('floating'),\n dock: () => setPlacement('docked'),\n setWidth: (next: number) => resize(next),\n };\n const latest = useRef(api); latest.current = api;\n // What `useDock` hands out: the current state, with actions that always reach this render's dock.\n const actions = useMemo(() => ({ setPlacement: (next: 'page' | 'docked' | 'floating') => latest.current.setPlacement(next), setOpen: (next: boolean) => latest.current.setOpen(next), toggle: () => latest.current.toggle(), float: () => latest.current.float(), dock: () => latest.current.dock(), setWidth: (next: number) => latest.current.setWidth(next) }), []);\n useLayoutEffect(() => { publish(id, { ...latest.current, ...actions }); });\n useLayoutEffect(() => () => publish(id, undefined), [id, publish]);\n const reported = useRef(placement);\n useLayoutEffect(() => { if (reported.current !== placement) { reported.current = placement; onPlacementChange?.(placement); } }, [placement, onPlacementChange]);\n\n const resize = (next: number) => update(id, { width: Math.min(Math.max(next, minWidth), max) });\n // Past `dragBelow` the dock keeps its last width; releasing there floats or closes it.\n const drag = (event: PointerEvent<HTMLElement>) => {\n if (!dragging) return;\n const box = section.current?.getBoundingClientRect();\n if (!box) return;\n const target = side === 'left' ? event.clientX - box.left : box.right - event.clientX;\n const below = dragBelow !== undefined && target < dragBelow;\n setHint(below);\n if (!below) resize(target);\n };\n const leave = () => { if (floatable) api.float(); else setOpen(false); };\n const release = (event: PointerEvent<HTMLElement>) => { event.currentTarget.releasePointerCapture?.(event.pointerId); setDragging(false); if (hint) leave(); setHint(false); };\n const keys = (event: KeyboardEvent<HTMLElement>) => {\n const outward = side === 'left' ? 'ArrowLeft' : 'ArrowRight', inward = side === 'left' ? 'ArrowRight' : 'ArrowLeft';\n if (event.altKey && event.key === outward && (floatable || dragBelow !== undefined)) { event.preventDefault(); leave(); return; }\n const step = event.shiftKey ? BIG_STEP : STEP;\n const next = event.key === inward ? width + step : event.key === outward ? width - step : event.key === 'Home' ? minWidth : event.key === 'End' ? max : undefined;\n if (next === undefined) return;\n event.preventDefault(); resize(next);\n };\n\n const docked = placement === 'docked';\n const divider = docked && <div role=\"separator\" tabIndex={0} aria-orientation=\"vertical\" aria-label={labels?.resize ?? 'Resize panel'} aria-valuemin={minWidth} aria-valuemax={max} aria-valuenow={width}\n data-testid={`dock-divider-${id}`} data-dragging={dragging ? 'true' : undefined}\n // No text selection or native drag from the divider: after a double-click reset, the next press must still resize.\n onPointerDown={event => { event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture?.(event.pointerId); setDragging(true); }} onPointerMove={drag}\n onDragStart={event => event.preventDefault()}\n onPointerUp={release} onPointerCancel={() => { setDragging(false); setHint(false); }} onKeyDown={keys} onDoubleClick={() => resize(defaultWidth)}\n className=\"group relative z-10 -mx-1.5 w-3 shrink-0 cursor-col-resize touch-none select-none outline-none\">\n <span aria-hidden=\"true\" className={cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/65 transition-[width,background-color] group-hover:w-0.5 group-hover:bg-ring group-focus-visible:w-0.5 group-focus-visible:bg-ring motion-reduce:transition-none', dragging && 'w-0.5 bg-ring')} />\n </div>;\n const hidden = placement === 'closed';\n const panel = <section ref={section} data-boring=\"dock\" data-dock={id} data-side={side} data-placement={placement} aria-label={labels?.region}\n {...(hidden ? { hidden: true, inert: true } : {})}\n style={docked ? { width } : undefined}\n className={cn('relative flex min-h-0 min-w-0 flex-col', docked && 'shrink-0 overflow-hidden bg-background text-foreground', (docked || placement === 'page') && contained && '[contain:layout]', placement === 'page' && 'flex-1 overflow-hidden bg-background text-foreground',\n placement === 'floating' && 'w-0 flex-none overflow-visible', placement === 'sheet' && 'fixed inset-0 z-50 bg-background text-foreground', hidden && 'hidden',\n dragging && '[&_iframe]:pointer-events-none select-none', className)}>\n {typeof children === 'function' ? children(api) : children}\n {hint && <div role=\"status\" data-testid={`dock-hint-${id}`} className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/75 p-4\">\n <span className=\"text-sm font-medium text-foreground\">\n {floatable ? labels?.floatHint ?? 'Release to float' : labels?.closeHint ?? 'Release to close'}</span>\n </div>}\n </section>;\n return side === 'left' ? <>{panel}{divider}</> : <>{divider}{panel}</>;\n}\n",
|
|
16
16
|
"type": "registry:component",
|
|
17
17
|
"target": "components/dock/dock.tsx"
|
|
18
18
|
}
|
package/r/documents.json
CHANGED
|
@@ -4,38 +4,41 @@
|
|
|
4
4
|
"title": "Documents: the Library, a file and its revisions",
|
|
5
5
|
"description": "The workspace's Library as the list, the chosen file in its viewer and its retained revisions beside it (an older one opens read-only). DocumentSheet opens one document over the app for a host's « Voir la fiche », with an optional « Toutes les fiches ». Every read goes through one revisionProvider binding.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.
|
|
8
|
-
"@hachej/boring-files@^0.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.0",
|
|
8
|
+
"@hachej/boring-files@^0.4.0",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"lucide-react@^1.51.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
20
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
21
|
-
"drawer"
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/theme.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/utils.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/button.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/pi-chat.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/pi-app.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/app-shell.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/file-tree.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/viewers.json",
|
|
21
|
+
"drawer",
|
|
22
|
+
"button",
|
|
23
|
+
"scroll-area",
|
|
24
|
+
"separator"
|
|
22
25
|
],
|
|
23
26
|
"files": [
|
|
24
27
|
{
|
|
25
28
|
"path": "registry/documents/documents.tsx",
|
|
26
|
-
"content": "'use client';\n\n// The documents screen: the workspace's Library is the list (`ListDetail`: beside the file on a wide screen, one pane at a time on a phone);\n// the chosen file opens in the same viewer the Library uses (editable where it edits); its Revisions sit beside it. Every revision row opens\n// that exact revision read-only, and « Back to editing » returns to the file. The editor stays mounted while a revision is inspected, so its\n// unsaved edits survive; leaving the file with unsaved edits asks first. Every read goes through the one `revisionProvider` the host binds.\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport { FileTreeView } from '../file-tree/file-tree';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport type { ViewerKind } from '@hachej/boring-files/file-types';\nimport { ListDetail } from '../app-shell/list-detail';\nimport { ArtifactPanel } from '../pi-app/artifact-panel';\nimport type { ArtifactDescriptor } from '../pi-chat/artifact';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport { ImagePane } from '../viewers/image-pane';\nimport { PdfPane } from '../viewers/pdf-pane';\nimport { ViewerFrame } from '../viewers/viewer-frame';\nimport { useViewerText } from '../viewers/viewer-window';\nimport { cn } from '../utils/utils';\nimport { Revisions } from './revisions';\nimport type { DocumentsLabels } from './documents-labels';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\n\nexport interface DocumentsProps {\n /** The workspace's revision provider (the files guide's `connectRevisionProvider`, or a host's own binding). Borrowed: the host disposes it. */\n readonly provider: RevisionProvider;\n readonly locale?: DocumentsLocale | undefined;\n /** The block's own words, over `documentsLabels` (the viewers' words follow `locale`). */\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n}\n\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\n/** The workspace's change feed as a counter: it moves on every change, so the listings and the revisions are read again. */\nfunction useWorkspaceVersion(provider: RevisionProvider): number {\n const [version, setVersion] = useState(0);\n useEffect(() => {\n if (!provider.changes) return undefined;\n const abort = new AbortController();\n void (async () => {\n try { for await (const change of provider.changes!({ signal: abort.signal })) { void change; setVersion(current => current + 1); } } catch { /* the binding ended the feed: the listings stay as they are */ }\n })();\n return () => abort.abort();\n }, [provider]);\n return version;\n}\n\n/** The Library's tree over the provider: created and disposed with the binding, read again when the workspace changes. */\nfunction useFileTree(provider: RevisionProvider, version: number): FileTreeController | undefined {\n const [tree, setTree] = useState<FileTreeController>();\n useEffect(() => {\n const next = createFileTreeController({ revisionProvider: provider });\n setTree(next);\n void next.refresh();\n return () => next.dispose();\n }, [provider]);\n useEffect(() => {\n if (!tree || version === 0) return;\n const { query, expanded } = tree.getSnapshot();\n if (query) void tree.search(query);\n else for (const directory of ['', ...expanded]) void tree.refresh(directory);\n }, [tree, version]);\n return tree;\n}\n\n/**\n * The provider as it reads one revision: every read of the returned client is the exact `revision`, over the same provider (a reload in a\n * pinned view re-reads that revision, never the latest). It is for read-only views only.\n */\nfunction pinnedProviderOf(provider: RevisionProvider, revision: string): RevisionProvider {\n return { ...provider, read: (request, signal) => provider.read({ target: request.target, revision: { kind: 'exact', value: revision } }, signal) };\n}\n\n/** An image or PDF revision, read exactly and shown in the viewer's read-only pane. */\nfunction BinaryRevision({ provider, path, revision, kind, labels, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly kind: 'image' | 'pdf';\n readonly labels: DocumentsLabels;\n readonly onClose: () => void;\n}) {\n const [state, setState] = useState<{ readonly kind: 'loading' } | { readonly kind: 'failed' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly mediaType: string }>({ kind: 'loading' });\n const { labels: viewer } = useViewerText();\n useEffect(() => {\n const abort = new AbortController();\n setState({ kind: 'loading' });\n provider.read({ target: provider.locate(path), revision: { kind: 'exact', value: revision } }, abort.signal).then(result => {\n if (!abort.signal.aborted) setState(result.kind === 'available' ? { kind: 'ready', bytes: result.snapshot.bytes, mediaType: result.snapshot.mediaType } : { kind: 'failed' });\n }, () => { if (!abort.signal.aborted) setState({ kind: 'failed' }); });\n return () => abort.abort();\n }, [provider, path, revision]);\n const status = { label: viewer.readOnly, kind: 'read-only' as const };\n if (state.kind !== 'ready') return <ViewerFrame title={nameOf(path)} status={status} onClose={onClose}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{state.kind === 'loading' ? labels.revisionsLoading : labels.revisionLoadFailed}</p>\n </ViewerFrame>;\n return kind === 'image'\n ? <ImagePane name={nameOf(path)} mediaType={state.mediaType} bytes={state.bytes} status={status} onClose={onClose} />\n : <PdfPane name={nameOf(path)} bytes={state.bytes} status={status} onClose={onClose} />;\n}\n\n/** One revision of the file, read-only, with « Back to editing ». Text, Markdown and HTML use the artifact panel's pinned view. */\nfunction OlderVersion({ provider, path, revision, options, labels, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n const kind: ViewerKind = viewerKindOf(path);\n const pinned = useMemo(() => pinnedProviderOf(provider, revision), [provider, revision]);\n const type = kind === 'markdown' ? 'markdown' : kind === 'html' ? 'html' : kind === 'text' ? 'code' : undefined;\n const artifact: ArtifactDescriptor | undefined = type === undefined ? undefined\n : { schema: 'boring.artifact', version: 1, title: nameOf(path), type, mediaType: mediaTypeOf(path), target: provider.locate(path), revision };\n const bar = <div className=\"flex shrink-0 items-center gap-2 border-b border-border px-3 py-2\">\n <span role=\"status\" className=\"min-w-0 flex-1 truncate text-sm font-medium\">{labels.readOnlyRevision}</span>\n <button type=\"button\" data-testid=\"back-to-editing\" onClick={onBack}\n className=\"inline-flex min-h-8 pointer-coarse:min-h-11 pointer-coarse:min-w-11 shrink-0 items-center rounded-lg border border-border bg-background px-3 text-xs pointer-coarse:text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60\">{labels.backToEditing}</button>\n </div>;\n let body;\n if (artifact) body = <ArtifactPanel active={{ descriptor: artifact, follow: false }} versions={[]} options={{ ...options, client: pinned }} onSelect={onBack} onClose={onClose} />;\n else if (kind === 'image' || kind === 'pdf') body = <BinaryRevision provider={provider} path={path} revision={revision} kind={kind} labels={labels} onClose={onClose} />;\n else body = <ViewerFrame title={nameOf(path)} onClose={onClose}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.unsupportedPreview}</p></ViewerFrame>;\n return <div data-testid=\"revision-view\" data-kind={kind} data-revision={revision} className=\"flex h-full min-h-0 flex-1 flex-col\">\n {bar}\n <div className=\"flex min-h-0 flex-1 flex-col\">{body}</div>\n </div>;\n}\n\nfunction DocumentDetail({ provider, path, version, options, labels, locale, older, onDirty, onOpenRevision, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly version: number;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly locale: DocumentsLocale;\n /** The older revision on show, `undefined` for the editable file. */\n readonly older: string | undefined;\n readonly onDirty: (unsaved: boolean) => void;\n readonly onOpenRevision: (revision: string) => void;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n return <div data-testid=\"documents-detail\" className=\"flex h-full min-h-0 flex-col md:flex-row [&_button]:pointer-coarse:min-h-11 [&_button]:pointer-coarse:min-w-11\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n {/* The editor stays mounted (hidden) while a revision is on show, so its unsaved edits are kept. */}\n <div className={cn('min-h-0 flex-1 flex-col', older === undefined ? 'flex' : 'hidden')}>\n <FileViewer path={path} locator={provider.locate(path)} options={options} onClose={onClose} onUnsavedChange={onDirty} />\n </div>\n {older !== undefined && <OlderVersion provider={provider} path={path} revision={older} options={options} labels={labels} onBack={onBack} onClose={onClose} />}\n </div>\n <aside className=\"flex max-h-[40%] min-h-0 shrink-0 flex-col overflow-y-auto border-t border-border md:max-h-none md:w-72 md:border-t-0 md:border-l\">\n <Revisions provider={provider} path={path} version={version} selected={older} locale={locale} labels={labels} onOpen={onOpenRevision} />\n </aside>\n </div>;\n}\n\nexport function Documents({ provider, locale, labels: given, viewers, share }: DocumentsProps) {\n const labels = documentsLabels(locale, given);\n const language: DocumentsLocale = locale === 'fr' ? 'fr' : 'en';\n const version = useWorkspaceVersion(provider);\n const tree = useFileTree(provider, version);\n const [path, setPath] = useState<string>();\n // An older revision of the file on show (`undefined`: the editable file).\n const [pinned, setPinned] = useState<{ readonly path: string; readonly revision: string }>();\n // The file's unsaved edits, as its viewer reports them. A change of file or a close that would drop them asks first.\n const dirty = useRef(false);\n const onDirty = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const options = useMemo<ViewerOptions>(() => ({ client: provider, identity: provider.identity, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }), [provider, viewers, share]);\n const choose = (next: string) => {\n if (next === path || !mayLeave()) return;\n setPath(next);\n setPinned(undefined);\n };\n const closeFile = () => { if (!mayLeave()) return; setPath(undefined); setPinned(undefined); };\n const list = tree ? <div className=\"min-h-0 [&_button]:pointer-coarse:min-h-11 [&_button]:pointer-coarse:min-w-11\">\n <FileTreeView controller={tree} onOpen={choose} selectedPath={path} locale={language} upload={false} history={false} />\n </div> : null;\n const older = path !== undefined && pinned?.path === path ? pinned.revision : undefined;\n // `null` (nothing chosen) is what lets a phone show the list alone: ListDetail stacks the detail only when there is one.\n const detail = path === undefined ? null\n : <DocumentDetail key={path} provider={provider} path={path} version={version} options={options} labels={labels} locale={language} older={older}\n onDirty={onDirty} onOpenRevision={revision => setPinned({ path, revision })} onBack={() => setPinned(undefined)} onClose={closeFile} />;\n return <DocumentsWords locale={locale}>\n <ListDetail list={list} detail={detail} onBack={closeFile} />\n </DocumentsWords>;\n}\n",
|
|
29
|
+
"content": "'use client';\n\n// The documents screen: the workspace's Library is the list (`ListDetail`: beside the file on a wide screen, one pane at a time on a phone);\n// the chosen file opens in the same viewer the Library uses (editable where it edits); its Revisions sit beside it. Every revision row opens\n// that exact revision read-only, and « Back to editing » returns to the file. The editor stays mounted while a revision is inspected, so its\n// unsaved edits survive; leaving the file with unsaved edits asks first. Every read goes through the one `revisionProvider` the host binds.\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport { FileTreeView } from '../file-tree/file-tree';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport type { ViewerKind } from '@hachej/boring-files/file-types';\nimport { ListDetail } from '../app-shell/list-detail';\nimport { ArtifactPanel } from '../pi-app/artifact-panel';\nimport type { ArtifactDescriptor } from '../pi-chat/artifact';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport { ImagePane } from '../viewers/image-pane';\nimport { PdfPane } from '../viewers/pdf-pane';\nimport { ViewerFrame } from '../viewers/viewer-frame';\nimport { useViewerText } from '../viewers/viewer-window';\nimport { cn } from '../utils/utils';\nimport { Revisions } from './revisions';\nimport type { DocumentsLabels } from './documents-labels';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\n\nexport interface DocumentsProps {\n /** The workspace's revision provider (the files guide's `connectRevisionProvider`, or a host's own binding). Borrowed: the host disposes it. */\n readonly provider: RevisionProvider;\n readonly locale?: DocumentsLocale | undefined;\n /** The block's own words, over `documentsLabels` (the viewers' words follow `locale`). */\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n}\n\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\n/** The workspace's change feed as a counter: it moves on every change, so the listings and the revisions are read again. */\nfunction useWorkspaceVersion(provider: RevisionProvider): number {\n const [version, setVersion] = useState(0);\n useEffect(() => {\n if (!provider.changes) return undefined;\n const abort = new AbortController();\n void (async () => {\n try { for await (const change of provider.changes!({ signal: abort.signal })) { void change; setVersion(current => current + 1); } } catch { /* the binding ended the feed: the listings stay as they are */ }\n })();\n return () => abort.abort();\n }, [provider]);\n return version;\n}\n\n/** The Library's tree over the provider: created and disposed with the binding, read again when the workspace changes. */\nfunction useFileTree(provider: RevisionProvider, version: number): FileTreeController | undefined {\n const [tree, setTree] = useState<FileTreeController>();\n useEffect(() => {\n const next = createFileTreeController({ revisionProvider: provider });\n setTree(next);\n void next.refresh();\n return () => next.dispose();\n }, [provider]);\n useEffect(() => {\n if (!tree || version === 0) return;\n const { query, expanded } = tree.getSnapshot();\n if (query) void tree.search(query);\n else for (const directory of ['', ...expanded]) void tree.refresh(directory);\n }, [tree, version]);\n return tree;\n}\n\n/**\n * The provider as it reads one revision: every read of the returned client is the exact `revision`, over the same provider (a reload in a\n * pinned view re-reads that revision, never the latest). It is for read-only views only.\n */\nfunction pinnedProviderOf(provider: RevisionProvider, revision: string): RevisionProvider {\n return { ...provider, read: (request, signal) => provider.read({ target: request.target, revision: { kind: 'exact', value: revision } }, signal) };\n}\n\n/** An image or PDF revision, read exactly and shown in the viewer's read-only pane. */\nfunction BinaryRevision({ provider, path, revision, kind, labels, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly kind: 'image' | 'pdf';\n readonly labels: DocumentsLabels;\n readonly onClose: () => void;\n}) {\n const [state, setState] = useState<{ readonly kind: 'loading' } | { readonly kind: 'failed' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly mediaType: string }>({ kind: 'loading' });\n const { labels: viewer } = useViewerText();\n useEffect(() => {\n const abort = new AbortController();\n setState({ kind: 'loading' });\n provider.read({ target: provider.locate(path), revision: { kind: 'exact', value: revision } }, abort.signal).then(result => {\n if (!abort.signal.aborted) setState(result.kind === 'available' ? { kind: 'ready', bytes: result.snapshot.bytes, mediaType: result.snapshot.mediaType } : { kind: 'failed' });\n }, () => { if (!abort.signal.aborted) setState({ kind: 'failed' }); });\n return () => abort.abort();\n }, [provider, path, revision]);\n const status = { label: viewer.readOnly, kind: 'read-only' as const };\n if (state.kind !== 'ready') return <ViewerFrame title={nameOf(path)} status={status} onClose={onClose}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{state.kind === 'loading' ? labels.revisionsLoading : labels.revisionLoadFailed}</p>\n </ViewerFrame>;\n return kind === 'image'\n ? <ImagePane name={nameOf(path)} mediaType={state.mediaType} bytes={state.bytes} status={status} onClose={onClose} />\n : <PdfPane name={nameOf(path)} bytes={state.bytes} status={status} onClose={onClose} />;\n}\n\n/** One revision of the file, read-only, with « Back to editing ». Text, Markdown and HTML use the artifact panel's pinned view. */\nfunction OlderVersion({ provider, path, revision, options, labels, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n const kind: ViewerKind = viewerKindOf(path);\n const pinned = useMemo(() => pinnedProviderOf(provider, revision), [provider, revision]);\n const type = kind === 'markdown' ? 'markdown' : kind === 'html' ? 'html' : kind === 'text' ? 'code' : undefined;\n const artifact: ArtifactDescriptor | undefined = type === undefined ? undefined\n : { schema: 'boring.artifact', version: 1, title: nameOf(path), type, mediaType: mediaTypeOf(path), target: provider.locate(path), revision };\n const bar = <>\n <div className=\"flex shrink-0 items-center gap-2 px-3 py-2\">\n <span role=\"status\" className=\"min-w-0 flex-1 truncate text-sm font-medium\">{labels.readOnlyRevision}</span>\n <Button variant=\"ghost\" data-testid=\"back-to-editing\" onClick={onBack}>{labels.backToEditing}</Button>\n </div>\n <Separator />\n </>;\n let body;\n if (artifact) body = <ArtifactPanel active={{ descriptor: artifact, follow: false }} versions={[]} options={{ ...options, client: pinned }} onSelect={onBack} onClose={onClose} />;\n else if (kind === 'image' || kind === 'pdf') body = <BinaryRevision provider={provider} path={path} revision={revision} kind={kind} labels={labels} onClose={onClose} />;\n else body = <ViewerFrame title={nameOf(path)} onClose={onClose}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.unsupportedPreview}</p></ViewerFrame>;\n return <div data-testid=\"revision-view\" data-kind={kind} data-revision={revision} className=\"flex h-full min-h-0 flex-1 flex-col\">\n {bar}\n <div className=\"flex min-h-0 flex-1 flex-col\">{body}</div>\n </div>;\n}\n\nfunction DocumentDetail({ provider, path, version, options, labels, locale, older, onDirty, onOpenRevision, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly version: number;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly locale: DocumentsLocale;\n /** The older revision on show, `undefined` for the editable file. */\n readonly older: string | undefined;\n readonly onDirty: (unsaved: boolean) => void;\n readonly onOpenRevision: (revision: string) => void;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n return <div data-testid=\"documents-detail\" className=\"flex h-full min-h-0 flex-col md:flex-row\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n {/* The editor stays mounted (hidden) while a revision is on show, so its unsaved edits are kept. */}\n <div className={cn('min-h-0 flex-1 flex-col', older === undefined ? 'flex' : 'hidden')}>\n <FileViewer path={path} locator={provider.locate(path)} options={options} onClose={onClose} onUnsavedChange={onDirty} />\n </div>\n {older !== undefined && <OlderVersion provider={provider} path={path} revision={older} options={options} labels={labels} onBack={onBack} onClose={onClose} />}\n </div>\n <Separator className=\"md:hidden\" />\n <Separator orientation=\"vertical\" className=\"max-md:hidden\" />\n <aside className=\"flex max-h-[40%] min-h-0 shrink-0 flex-col overflow-hidden md:max-h-none md:w-72\">\n <Revisions provider={provider} path={path} version={version} selected={older} locale={locale} labels={labels} onOpen={onOpenRevision} />\n </aside>\n </div>;\n}\n\nexport function Documents({ provider, locale, labels: given, viewers, share }: DocumentsProps) {\n const labels = documentsLabels(locale, given);\n const language: DocumentsLocale = locale === 'fr' ? 'fr' : 'en';\n const version = useWorkspaceVersion(provider);\n const tree = useFileTree(provider, version);\n const [path, setPath] = useState<string>();\n // An older revision of the file on show (`undefined`: the editable file).\n const [pinned, setPinned] = useState<{ readonly path: string; readonly revision: string }>();\n // The file's unsaved edits, as its viewer reports them. A change of file or a close that would drop them asks first.\n const dirty = useRef(false);\n const onDirty = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const options = useMemo<ViewerOptions>(() => ({ client: provider, identity: provider.identity, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }), [provider, viewers, share]);\n const choose = (next: string) => {\n if (next === path || !mayLeave()) return;\n setPath(next);\n setPinned(undefined);\n };\n const closeFile = () => { if (!mayLeave()) return; setPath(undefined); setPinned(undefined); };\n const list = tree ? <div className=\"min-h-0\">\n <FileTreeView controller={tree} onOpen={choose} selectedPath={path} locale={language} upload={false} history={false} />\n </div> : null;\n const older = path !== undefined && pinned?.path === path ? pinned.revision : undefined;\n // `null` (nothing chosen) is what lets a phone show the list alone: ListDetail stacks the detail only when there is one.\n const detail = path === undefined ? null\n : <DocumentDetail key={path} provider={provider} path={path} version={version} options={options} labels={labels} locale={language} older={older}\n onDirty={onDirty} onOpenRevision={revision => setPinned({ path, revision })} onBack={() => setPinned(undefined)} onClose={closeFile} />;\n return <DocumentsWords locale={locale}>\n <ListDetail list={list} detail={detail} onBack={closeFile} />\n </DocumentsWords>;\n}\n",
|
|
27
30
|
"type": "registry:component",
|
|
28
31
|
"target": "components/documents/documents.tsx"
|
|
29
32
|
},
|
|
30
33
|
{
|
|
31
34
|
"path": "registry/documents/document-sheet.tsx",
|
|
32
|
-
"content": "'use client';\n\n// One document over the app, for « Voir la fiche »: the host sets `path` from its own handler (for example its `onOpenSpec`) and clears it in\n// `onClose`. The sheet is a full-screen view of the shell (`useShellLayer`): it closes the agent layers when it opens, and Back closes it\n// first. The file is the same viewer the Library opens (editable where the viewer edits). Closing a file with unsaved edits asks first.\n// `onShowAll` adds « Toutes les fiches »: the sheet closes, then the host's navigation runs after the sheet's Back entry is gone.\nimport { useCallback, useEffect, useRef } from 'react';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { afterBack } from '../app-shell/back';\nimport { useShellLayer } from '../app-shell/shell-layer';\nimport { useExclusiveSheet } from '../app-shell/sheet';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { cn } from '../utils/utils';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\nimport type { DocumentsLabels } from './documents-labels';\n\nexport interface DocumentSheetProps {\n readonly provider: RevisionProvider;\n /** The workspace path of the document on show; `undefined` closes the sheet. */\n readonly path: string | undefined;\n /** Called when the sheet closes (its close button, a swipe, Back, « Toutes les fiches »): the host clears `path`. */\n readonly onClose: () => void;\n /** Adds « Toutes les fiches » to the sheet. The sheet closes first, then this runs. */\n readonly onShowAll?: (() => void) | undefined;\n readonly locale?: DocumentsLocale | undefined;\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n readonly className?: string | undefined;\n}\n\n/** The sheet's name in the shell's layers: one sheet of this kind at a time. */\nconst LAYER = 'document';\n\nexport function DocumentSheet({ provider, path, onClose, onShowAll, locale, labels: given, viewers, share, className }: DocumentSheetProps) {\n const shown = path !== undefined;\n const labels = documentsLabels(locale, given);\n // The viewer reports its unsaved edits; a close that would drop them asks first (the Back path too, through the shell layer).\n const dirty = useRef(false);\n const report = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const { open, close, isOpen } = useShellLayer(LAYER, { confirmBack: mayLeave });\n // The host's `path` decides whether the sheet is on show; a close from the sheet itself reports to the host.\n useEffect(() => { if (shown) open(); else close(); }, [shown, open, close]);\n useExclusiveSheet(isOpen, close);\n const wasOpen = useRef(false);\n useEffect(() => {\n if (shown && wasOpen.current && !isOpen) onClose();\n wasOpen.current = isOpen;\n }, [isOpen, shown, onClose]);\n const requestClose = () => { if (mayLeave()) close(); };\n const title = path === undefined ? labels.sheetTitle : path.split('/').pop() || path;\n return <DocumentsWords locale={locale}>\n <Drawer open={isOpen} onOpenChange={next => { if (!next) requestClose(); }} showSwipeHandle swipeDirection=\"down\">\n <DrawerContent data-testid=\"document-sheet\" className={cn('app-shell h-[92dvh]
|
|
35
|
+
"content": "'use client';\n\n// One document over the app, for « Voir la fiche »: the host sets `path` from its own handler (for example its `onOpenSpec`) and clears it in\n// `onClose`. The sheet is a full-screen view of the shell (`useShellLayer`): it closes the agent layers when it opens, and Back closes it\n// first. The file is the same viewer the Library opens (editable where the viewer edits). Closing a file with unsaved edits asks first.\n// `onShowAll` adds « Toutes les fiches »: the sheet closes, then the host's navigation runs after the sheet's Back entry is gone.\nimport { useCallback, useEffect, useRef } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { afterBack } from '../app-shell/back';\nimport { useShellLayer } from '../app-shell/shell-layer';\nimport { useExclusiveSheet } from '../app-shell/use-exclusive-sheet';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { cn } from '../utils/utils';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\nimport type { DocumentsLabels } from './documents-labels';\n\nexport interface DocumentSheetProps {\n readonly provider: RevisionProvider;\n /** The workspace path of the document on show; `undefined` closes the sheet. */\n readonly path: string | undefined;\n /** Called when the sheet closes (its close button, a swipe, Back, « Toutes les fiches »): the host clears `path`. */\n readonly onClose: () => void;\n /** Adds « Toutes les fiches » to the sheet. The sheet closes first, then this runs. */\n readonly onShowAll?: (() => void) | undefined;\n readonly locale?: DocumentsLocale | undefined;\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n readonly className?: string | undefined;\n}\n\n/** The sheet's name in the shell's layers: one sheet of this kind at a time. */\nconst LAYER = 'document';\n\nexport function DocumentSheet({ provider, path, onClose, onShowAll, locale, labels: given, viewers, share, className }: DocumentSheetProps) {\n const shown = path !== undefined;\n const labels = documentsLabels(locale, given);\n // The viewer reports its unsaved edits; a close that would drop them asks first (the Back path too, through the shell layer).\n const dirty = useRef(false);\n const report = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const { open, close, isOpen } = useShellLayer(LAYER, { confirmBack: mayLeave });\n // The host's `path` decides whether the sheet is on show; a close from the sheet itself reports to the host.\n useEffect(() => { if (shown) open(); else close(); }, [shown, open, close]);\n useExclusiveSheet(isOpen, close);\n const wasOpen = useRef(false);\n useEffect(() => {\n if (shown && wasOpen.current && !isOpen) onClose();\n wasOpen.current = isOpen;\n }, [isOpen, shown, onClose]);\n const requestClose = () => { if (mayLeave()) close(); };\n const title = path === undefined ? labels.sheetTitle : path.split('/').pop() || path;\n return <DocumentsWords locale={locale}>\n <Drawer open={isOpen} onOpenChange={next => { if (!next) requestClose(); }} showSwipeHandle swipeDirection=\"down\">\n <DrawerContent data-testid=\"document-sheet\" className={cn('app-shell max-h-[92dvh] min-h-[92dvh]', className)}>\n {/* The title is for assistive technology: the viewer's own bar shows the file name. */}\n <DrawerTitle className=\"sr-only\">{title}</DrawerTitle>\n {onShowAll && <div className=\"flex shrink-0 justify-end px-3 pt-1 pb-2\">\n <Button variant=\"ghost\" data-testid=\"document-sheet-all\" onClick={() => { if (!mayLeave()) return; close(); afterBack(onShowAll); }}>{labels.allDocuments}</Button>\n </div>}\n <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden pb-[env(safe-area-inset-bottom)]\">\n {path !== undefined && <FileViewer key={path} path={path} locator={provider.locate(path)} onUnsavedChange={report}\n options={{ client: provider, identity: provider.identity, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }} onClose={requestClose} />}\n </div>\n </DrawerContent>\n </Drawer>\n </DocumentsWords>;\n}\n",
|
|
33
36
|
"type": "registry:component",
|
|
34
37
|
"target": "components/documents/document-sheet.tsx"
|
|
35
38
|
},
|
|
36
39
|
{
|
|
37
40
|
"path": "registry/documents/revisions.tsx",
|
|
38
|
-
"content": "'use client';\n\n// The revisions of one file: what the workspace's history retains for it (`provider.history`, newest first), each with when it was saved and\n// who wrote it (`source`). Every row opens that exact revision read-only; going back to editing is a separate control in the documents\n// screen. The operation id is not shown: the public history carries none (a follow-up for the files seam).\nimport { useEffect, useState } from 'react';\nimport
|
|
41
|
+
"content": "'use client';\n\n// The revisions of one file: what the workspace's history retains for it (`provider.history`, newest first), each with when it was saved and\n// who wrote it (`source`). Every row opens that exact revision read-only; going back to editing is a separate control in the documents\n// screen. The operation id is not shown: the public history carries none (a follow-up for the files seam).\nimport { useEffect, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport type { RevisionProvider, SavedRevision } from '@hachej/boring-files/revision';\nimport type { DocumentsLabels } from './documents-labels';\n\ninterface Loaded { readonly provider: RevisionProvider; readonly path: string; readonly saves: readonly SavedRevision[] }\n\n/**\n * The retained revisions of `path` read from `provider`, read again when `version` changes. The answer is kept with the provider and the\n * path it was read for: another provider or path gets `undefined` at once, and a late answer for one of them is dropped.\n */\nexport function useRevisions(provider: RevisionProvider, path: string, version: number): readonly SavedRevision[] | undefined {\n const [loaded, setLoaded] = useState<Loaded>();\n useEffect(() => {\n const abort = new AbortController();\n const save = (saves: readonly SavedRevision[]) => { if (!abort.signal.aborted) setLoaded({ provider, path, saves }); };\n provider.history(path, abort.signal).then(save, () => save([]));\n return () => abort.abort();\n }, [provider, path, version]);\n return loaded && loaded.provider === provider && loaded.path === path ? loaded.saves : undefined;\n}\n\n/** When a revision was saved, in the page's language; `savedAt` 0 means the journal did not record a time. */\nexport function whenSaved(savedAt: number, locale: string, unknown: string): string {\n if (!savedAt) return unknown;\n return new Date(savedAt).toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' });\n}\n\n/** The panel's list: one row per retained revision, newest first. Each row opens its own revision read-only. */\nexport function Revisions({ provider, path, version, selected, locale, labels, onOpen }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n /** Bumped when the workspace's change feed moves, so the list is read again. */\n readonly version: number;\n /** The revision on show in the read-only view, if any. */\n readonly selected: string | undefined;\n readonly locale: string;\n readonly labels: DocumentsLabels;\n readonly onOpen: (revision: string) => void;\n}) {\n const saves = useRevisions(provider, path, version);\n return <section data-testid=\"revisions\" aria-label={labels.revisions} className=\"flex min-h-0 flex-col\">\n <h2 className=\"m-0 px-3 py-2 text-sm font-semibold\">{labels.revisions}</h2>\n {saves === undefined ? <p role=\"status\" className=\"m-0 px-3 pb-3 text-sm text-muted-foreground\">{labels.revisionsLoading}</p>\n : saves.length === 0 ? <p role=\"status\" className=\"m-0 px-3 pb-3 text-sm text-muted-foreground\">{labels.noRevisions}</p>\n : <ScrollArea className=\"min-h-0 flex-1\"><ol className=\"m-0 list-none p-1\">\n {saves.map((save, index) => {\n const current = index === 0;\n const on = selected === save.revision;\n const when = whenSaved(save.savedAt, locale, labels.unknownTime);\n const who = save.source ? labels.savedBy[save.source] : labels.savedBy.observed;\n return <li key={save.revision}>\n <Button type=\"button\" variant={on ? 'secondary' : 'ghost'} data-testid=\"revision-item\" data-revision={save.revision} data-current={current ? 'true' : undefined}\n aria-current={on ? 'true' : undefined} aria-label={labels.openRevision(when)} onClick={() => onOpen(save.revision)}\n className=\"h-auto min-h-11 w-full flex-col items-start justify-start gap-0.5 whitespace-normal text-left\">\n <span className=\"font-medium\">{current ? labels.latest : when}</span>\n <span className=\"text-xs text-muted-foreground\">{current ? `${when}, ${who}` : who}</span>\n </Button>\n </li>;\n })}\n </ol></ScrollArea>}\n </section>;\n}\n",
|
|
39
42
|
"type": "registry:component",
|
|
40
43
|
"target": "components/documents/revisions.tsx"
|
|
41
44
|
},
|
package/r/feedback-preview.json
CHANGED
|
@@ -4,14 +4,20 @@
|
|
|
4
4
|
"title": "Feedback preview",
|
|
5
5
|
"description": "PreviewBanner: a live, reversible preview of a change in the page (Approve / Discard) over `@hachej/boring-feedback/preview`, whose subagent calls models through a host gateway. Separate from `feedback` so annotation-only apps do not install the model SDK; install `feedback` first (its styles cover the banner).",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.
|
|
7
|
+
"@hachej/boring-feedback@^0.4.0",
|
|
8
8
|
"@earendil-works/pi-ai@~1.2.0",
|
|
9
9
|
"react@^19.0.0"
|
|
10
10
|
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"button",
|
|
13
|
+
"card",
|
|
14
|
+
"badge",
|
|
15
|
+
"input"
|
|
16
|
+
],
|
|
11
17
|
"files": [
|
|
12
18
|
{
|
|
13
19
|
"path": "registry/feedback-preview/preview-banner.tsx",
|
|
14
|
-
"content": "// The preview banner (FEEDBACK.md, \"Preview\"): while the in-page preview subagent changes the live page for the builder's\n// `browser_preview` call, a fixed, clearly labelled banner says nothing is saved, lists the changes, lets the person keep talking to\n// the subagent (\"darker\"), and offers Approve and Discard. Everything it shows and does is the headless `createPreviewSession` from\n// `@hachej/boring-feedback/preview`; this file is how it looks. It is `data-feedback-ignore`, so the preview tools and every snapshot skip it.\nimport { useState, useSyncExternalStore, type FormEvent } from 'react';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport type { PreviewSession, PreviewSnapshot } from '@hachej/boring-feedback/preview';\n\nconst STATUS: Readonly<Record<PreviewSnapshot['status'], string>> = {\n starting: 'Preparing the preview…', working: 'Previewing…', ready: 'Preview ready', failed: 'Preview failed',\n answering: 'Sending…', approved: 'Approved', discarded: 'Discarded',\n};\n\nexport interface PreviewBannerProps {\n readonly session: PreviewSession;\n}\n\nexport function PreviewBanner({ session }: PreviewBannerProps) {\n const state = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);\n const [text, setText] = useState('');\n const busy = state.status === 'starting' || state.status === 'working' || state.status === 'answering';\n const done = state.status === 'approved' || state.status === 'discarded';\n const submit = (event: FormEvent) => {\n event.preventDefault();\n const said = text.trim();\n if (!said || busy) return;\n setText('');\n void session.say(said);\n };\n return <
|
|
20
|
+
"content": "// The preview banner (FEEDBACK.md, \"Preview\"): while the in-page preview subagent changes the live page for the builder's\n// `browser_preview` call, a fixed, clearly labelled banner says nothing is saved, lists the changes, lets the person keep talking to\n// the subagent (\"darker\"), and offers Approve and Discard. Everything it shows and does is the headless `createPreviewSession` from\n// `@hachej/boring-feedback/preview`; this file is how it looks. It is `data-feedback-ignore`, so the preview tools and every snapshot skip it.\nimport { useState, useSyncExternalStore, type FormEvent } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Input } from '@/components/ui/input';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport type { PreviewSession, PreviewSnapshot } from '@hachej/boring-feedback/preview';\n\nconst STATUS: Readonly<Record<PreviewSnapshot['status'], string>> = {\n starting: 'Preparing the preview…', working: 'Previewing…', ready: 'Preview ready', failed: 'Preview failed',\n answering: 'Sending…', approved: 'Approved', discarded: 'Discarded',\n};\n\nexport interface PreviewBannerProps {\n readonly session: PreviewSession;\n}\n\nexport function PreviewBanner({ session }: PreviewBannerProps) {\n const state = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);\n const [text, setText] = useState('');\n const busy = state.status === 'starting' || state.status === 'working' || state.status === 'answering';\n const done = state.status === 'approved' || state.status === 'discarded';\n const submit = (event: FormEvent) => {\n event.preventDefault();\n const said = text.trim();\n if (!said || busy) return;\n setText('');\n void session.say(said);\n };\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"region\" aria-label=\"Preview of a change\" aria-live=\"polite\"\n data-testid=\"preview-banner\" data-status={state.status} className=\"boring-feedback-preview\">\n <div className=\"boring-feedback-preview-head\">\n <Badge>Preview</Badge>\n <span className=\"boring-feedback-preview-notice\">these changes are not saved</span>\n <span className=\"boring-feedback-preview-status\" data-testid=\"preview-status\">{STATUS[state.status]}</span>\n </div>\n {state.summary && <p className=\"boring-feedback-preview-summary\" data-testid=\"preview-summary\">{state.summary}</p>}\n {state.changes.length > 0 && <ul className=\"boring-feedback-preview-changes\" data-testid=\"preview-changes\">\n {state.changes.map((change, index) => {\n const element = readableElement(change.element, change.source);\n return <li key={index} data-testid=\"preview-change\">\n <span title={element.path}>{element.name}{element.file ? ` · ${element.file}` : ''}</span>{' '}\n <code>{change.text ? 'text' : change.property}</code> {change.from || '—'} → <strong>{change.to}</strong>\n </li>;\n })}\n </ul>}\n {state.error && <p className=\"boring-feedback-preview-error\" role=\"alert\" data-testid=\"preview-error\">{state.error}</p>}\n {!done && <form className=\"boring-feedback-preview-actions\" onSubmit={submit}>\n <Input className=\"boring-feedback-preview-input\" data-testid=\"preview-input\" value={text} maxLength={300} disabled={busy}\n aria-label=\"Tell the preview what to change\" placeholder=\"Adjust it… (e.g. darker)\" onChange={event => setText(event.currentTarget.value)} />\n <Button type=\"submit\" variant=\"default\" data-testid=\"preview-say\" disabled={busy || !text.trim()}>Send</Button>\n <Button variant=\"default\" data-testid=\"preview-approve\" disabled={busy || state.changes.length === 0}\n onClick={() => { void session.approve(); }}>Approve</Button>\n <Button variant=\"ghost\" data-testid=\"preview-discard\" disabled={state.status === 'answering'}\n onClick={() => { void session.discard(); }}>Discard</Button>\n </form>}\n </Card>;\n}\n",
|
|
15
21
|
"type": "registry:component",
|
|
16
22
|
"target": "components/feedback-preview/preview-banner.tsx"
|
|
17
23
|
}
|