@hachej/boring-ui-kit 0.1.111 → 0.2.1
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/LICENSE +1 -1
- package/README.md +255 -239
- package/THIRD_PARTY_NOTICES.md +85 -0
- package/dist/anchor-conformance.d.ts +78 -0
- package/dist/anchor-conformance.js +288 -0
- package/dist/canvas-document.d.ts +25 -0
- package/dist/canvas-document.js +141 -0
- package/dist/canvas-editor-native.d.ts +22 -0
- package/dist/canvas-editor-native.js +112 -0
- package/dist/canvas-editor.d.ts +6 -0
- package/dist/canvas-editor.js +13 -0
- package/dist/canvas-mounted.d.ts +41 -0
- package/dist/canvas-mounted.js +180 -0
- package/dist/canvas-proposals.d.ts +11 -0
- package/dist/canvas-proposals.js +45 -0
- package/dist/canvas-records.d.ts +3 -0
- package/dist/canvas-records.js +39 -0
- package/dist/canvas.d.ts +92 -0
- package/dist/canvas.js +264 -0
- package/dist/contracts.d.ts +125 -0
- package/dist/contracts.js +1 -0
- package/dist/experience-catalog.d.ts +142 -0
- package/dist/experience-catalog.js +25 -0
- package/dist/experience-compose.d.ts +83 -0
- package/dist/experience-compose.js +218 -0
- package/dist/experience-composition-schema.d.ts +29 -0
- package/dist/experience-composition-schema.js +22 -0
- package/dist/experience-document-viewer.d.ts +13 -0
- package/dist/experience-document-viewer.js +54 -0
- package/dist/experience-document.d.ts +64 -0
- package/dist/experience-document.js +234 -0
- package/dist/experience-region-text.d.ts +3 -0
- package/dist/experience-region-text.js +84 -0
- package/dist/experience-region-tree.d.ts +22 -0
- package/dist/experience-region-tree.js +50 -0
- package/dist/experience-regions.d.ts +9 -0
- package/dist/experience-regions.js +122 -0
- package/dist/experience-renderer.d.ts +16 -0
- package/dist/experience-renderer.js +37 -0
- package/dist/experience.d.ts +12 -0
- package/dist/experience.js +23 -0
- package/dist/file-tree-shadcn.d.ts +8 -0
- package/dist/file-tree-shadcn.js +14 -0
- package/dist/file-tree-view.d.ts +66 -0
- package/dist/file-tree-view.js +256 -0
- package/dist/file-tree.d.ts +57 -0
- package/dist/file-tree.js +204 -0
- package/dist/html-preview.d.ts +2 -0
- package/dist/html-preview.js +47 -0
- package/dist/html-viewer.d.ts +13 -0
- package/dist/html-viewer.js +69 -0
- package/dist/html.d.ts +21 -0
- package/dist/html.js +21 -0
- package/dist/index.d.ts +2 -472
- package/dist/index.js +1 -2671
- package/dist/markdown-editor.d.ts +28 -0
- package/dist/markdown-editor.js +230 -0
- package/dist/markdown-extensions.d.ts +16 -0
- package/dist/markdown-extensions.js +96 -0
- package/dist/markdown-icons.d.ts +244 -0
- package/dist/markdown-icons.js +32 -0
- package/dist/markdown-mounted.d.ts +63 -0
- package/dist/markdown-mounted.js +263 -0
- package/dist/markdown-parser.d.ts +4 -0
- package/dist/markdown-parser.js +33 -0
- package/dist/markdown-proposal-diff.d.ts +5 -0
- package/dist/markdown-proposal-diff.js +122 -0
- package/dist/markdown-safety.d.ts +21 -0
- package/dist/markdown-safety.js +135 -0
- package/dist/markdown.d.ts +86 -0
- package/dist/markdown.js +121 -0
- package/dist/native-chat.d.ts +150 -0
- package/dist/native-chat.js +376 -0
- package/dist/pi.d.ts +12 -0
- package/dist/pi.js +1 -0
- package/dist/public-text-buffer.d.ts +5 -0
- package/dist/public-text-buffer.js +1 -0
- package/dist/remote-chat.d.ts +76 -0
- package/dist/remote-chat.js +324 -0
- package/dist/resources.d.ts +34 -0
- package/dist/resources.js +1 -0
- package/dist/text-buffer-state.d.ts +24 -0
- package/dist/text-buffer-state.js +1 -0
- package/dist/text-buffer.d.ts +52 -0
- package/dist/text-buffer.js +315 -0
- package/dist/text-draft-controls.d.ts +9 -0
- package/dist/text-draft-controls.js +33 -0
- package/dist/text-draft-types.d.ts +111 -0
- package/dist/text-draft-types.js +1 -0
- package/dist/text-drafts.d.ts +29 -0
- package/dist/text-drafts.js +378 -0
- package/package.json +226 -58
- package/styles/file-tree.css +46 -0
- package/dist/styles.css +0 -2308
- package/dist/tokens.css +0 -155
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import * as Collapsible from '@radix-ui/react-collapsible';
|
|
4
|
+
import { ChevronRight, File, Folder } from 'lucide-react';
|
|
5
|
+
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub } from './file-tree-shadcn.js';
|
|
6
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
7
|
+
import { createFileTreeController } from './file-tree.js';
|
|
8
|
+
/** Every word of the tree, so a host can translate it. Functions receive what they name. */
|
|
9
|
+
export const defaultFileTreeLabels = {
|
|
10
|
+
region: 'Workspace files',
|
|
11
|
+
tree: 'Files',
|
|
12
|
+
search: 'Search files',
|
|
13
|
+
searchPlaceholder: 'Search files…',
|
|
14
|
+
refresh: 'Refresh',
|
|
15
|
+
upload: 'Upload',
|
|
16
|
+
uploadFiles: 'Upload files',
|
|
17
|
+
uploads: 'Uploads',
|
|
18
|
+
loading: 'Loading files…',
|
|
19
|
+
retry: 'Retry',
|
|
20
|
+
noMatches: 'No matching files.',
|
|
21
|
+
empty: 'No files here.',
|
|
22
|
+
loadMore: 'Load more',
|
|
23
|
+
history: 'History',
|
|
24
|
+
historyOf: (path) => `History of ${path}`,
|
|
25
|
+
closeHistory: 'Close history',
|
|
26
|
+
loadingHistory: 'Loading history…',
|
|
27
|
+
noHistory: 'No retained versions.',
|
|
28
|
+
earlierVersion: 'Earlier version',
|
|
29
|
+
historyUnavailable: 'History unavailable',
|
|
30
|
+
revisionUnavailable: 'This revision is unavailable',
|
|
31
|
+
readOnlyRevision: (bytes) => `Read-only revision · ${bytes} bytes`,
|
|
32
|
+
binaryFile: (mediaType) => `Binary file (${mediaType})`,
|
|
33
|
+
uploading: 'Uploading…',
|
|
34
|
+
saved: 'Saved',
|
|
35
|
+
unconfirmed: 'Unconfirmed',
|
|
36
|
+
checkUpload: 'Check upload status',
|
|
37
|
+
uploadStatusUnavailable: 'Upload status unavailable',
|
|
38
|
+
uploadFailed: (name) => `${name}: upload failed`,
|
|
39
|
+
};
|
|
40
|
+
/** The tree's words in French (`locale="fr"`). */
|
|
41
|
+
export const frenchFileTreeLabels = {
|
|
42
|
+
region: 'Fichiers de l’espace',
|
|
43
|
+
tree: 'Fichiers',
|
|
44
|
+
search: 'Rechercher un fichier',
|
|
45
|
+
searchPlaceholder: 'Rechercher un fichier…',
|
|
46
|
+
refresh: 'Actualiser',
|
|
47
|
+
upload: 'Importer',
|
|
48
|
+
uploadFiles: 'Importer des fichiers',
|
|
49
|
+
uploads: 'Imports',
|
|
50
|
+
loading: 'Chargement des fichiers…',
|
|
51
|
+
retry: 'Réessayer',
|
|
52
|
+
noMatches: 'Aucun fichier ne correspond.',
|
|
53
|
+
empty: 'Aucun fichier ici.',
|
|
54
|
+
loadMore: 'Afficher plus',
|
|
55
|
+
history: 'Historique',
|
|
56
|
+
historyOf: path => `Historique de ${path}`,
|
|
57
|
+
closeHistory: 'Fermer l’historique',
|
|
58
|
+
loadingHistory: 'Chargement de l’historique…',
|
|
59
|
+
noHistory: 'Aucune version conservée.',
|
|
60
|
+
earlierVersion: 'Version antérieure',
|
|
61
|
+
historyUnavailable: 'Historique indisponible',
|
|
62
|
+
revisionUnavailable: 'Cette version n’est pas disponible',
|
|
63
|
+
readOnlyRevision: bytes => `Version en lecture seule · ${bytes} octets`,
|
|
64
|
+
binaryFile: mediaType => `Fichier binaire (${mediaType})`,
|
|
65
|
+
uploading: 'Import en cours…',
|
|
66
|
+
saved: 'Enregistré',
|
|
67
|
+
unconfirmed: 'Non confirmé',
|
|
68
|
+
checkUpload: 'Vérifier l’import',
|
|
69
|
+
uploadStatusUnavailable: 'État de l’import indisponible',
|
|
70
|
+
uploadFailed: name => `${name} : échec de l’import`,
|
|
71
|
+
};
|
|
72
|
+
const LOCALES = { en: defaultFileTreeLabels, fr: frenchFileTreeLabels };
|
|
73
|
+
/** One borrowed binding powers browsing, search, upload and history. Historical previews never replace an editor's draft. */
|
|
74
|
+
export function FileTree(props) {
|
|
75
|
+
return _jsx(BoundFileTree, { ...props }, JSON.stringify([props.revisionProvider.workspace, props.revisionProvider.identity]));
|
|
76
|
+
}
|
|
77
|
+
function BoundFileTree(props) {
|
|
78
|
+
const [owned, setOwned] = useState();
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const controller = createFileTreeController({ revisionProvider: props.revisionProvider });
|
|
81
|
+
setOwned({ provider: props.revisionProvider, controller });
|
|
82
|
+
void controller.refresh();
|
|
83
|
+
return () => controller.dispose();
|
|
84
|
+
}, [props.revisionProvider]);
|
|
85
|
+
return owned?.provider === props.revisionProvider ? _jsx(FileTreeView, { ...props, controller: owned.controller }) : _jsx("p", { role: "status", children: props.labels?.loading ?? LOCALES[props.locale ?? 'en'].loading });
|
|
86
|
+
}
|
|
87
|
+
/** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
|
|
88
|
+
export function FileTreeView({ controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refreshKey, historyKey }) {
|
|
89
|
+
const labels = { ...LOCALES[locale], ...given };
|
|
90
|
+
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
91
|
+
const shownKey = useRef(refreshKey);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (Object.is(shownKey.current, refreshKey))
|
|
94
|
+
return;
|
|
95
|
+
shownKey.current = refreshKey;
|
|
96
|
+
const { query, expanded } = controller.getSnapshot();
|
|
97
|
+
if (query)
|
|
98
|
+
void controller.search(query);
|
|
99
|
+
else
|
|
100
|
+
for (const directory of ['', ...expanded])
|
|
101
|
+
void controller.refresh(directory);
|
|
102
|
+
}, [controller, refreshKey]);
|
|
103
|
+
const tree = useRef(null);
|
|
104
|
+
const [focused, setFocused] = useState('');
|
|
105
|
+
const [uploadError, setUploadError] = useState('');
|
|
106
|
+
const [historyPath, setHistoryPath] = useState();
|
|
107
|
+
const rows = [];
|
|
108
|
+
function flatten(directory, level) {
|
|
109
|
+
for (const entry of state.directories.get(directory)?.entries ?? []) {
|
|
110
|
+
rows.push({ entry, level });
|
|
111
|
+
if (entry.kind === 'directory' && state.expanded.has(entry.path))
|
|
112
|
+
flatten(entry.path, level + 1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (state.query)
|
|
116
|
+
for (const entry of state.search.entries)
|
|
117
|
+
rows.push({ entry, level: 1 });
|
|
118
|
+
else
|
|
119
|
+
flatten('', 1);
|
|
120
|
+
const focusPath = rows.some(row => row.entry.path === focused) ? focused : rows[0]?.entry.path;
|
|
121
|
+
const focus = (path) => {
|
|
122
|
+
if (path === undefined)
|
|
123
|
+
return;
|
|
124
|
+
setFocused(path);
|
|
125
|
+
const nodes = tree.current?.querySelectorAll('[role=treeitem]');
|
|
126
|
+
for (const node of Array.from(nodes ?? []))
|
|
127
|
+
if (node.dataset.path === path)
|
|
128
|
+
node.focus();
|
|
129
|
+
};
|
|
130
|
+
const keyboard = (event, entry) => {
|
|
131
|
+
const index = rows.findIndex(row => row.entry.path === entry.path);
|
|
132
|
+
const expanded = state.expanded.has(entry.path);
|
|
133
|
+
switch (event.key) {
|
|
134
|
+
case 'ArrowDown':
|
|
135
|
+
focus(rows[Math.min(rows.length - 1, index + 1)]?.entry.path);
|
|
136
|
+
break;
|
|
137
|
+
case 'ArrowUp':
|
|
138
|
+
focus(rows[Math.max(0, index - 1)]?.entry.path);
|
|
139
|
+
break;
|
|
140
|
+
case 'Home':
|
|
141
|
+
focus(rows[0]?.entry.path);
|
|
142
|
+
break;
|
|
143
|
+
case 'End':
|
|
144
|
+
focus(rows.at(-1)?.entry.path);
|
|
145
|
+
break;
|
|
146
|
+
case 'ArrowRight':
|
|
147
|
+
if (entry.kind === 'directory' && !state.query) {
|
|
148
|
+
if (!expanded)
|
|
149
|
+
void controller.toggle(entry.path);
|
|
150
|
+
else {
|
|
151
|
+
const next = rows[index + 1];
|
|
152
|
+
if (next && next.level > (rows[index]?.level ?? 0))
|
|
153
|
+
focus(next.entry.path);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
case 'ArrowLeft':
|
|
158
|
+
if (entry.kind === 'directory' && expanded && !state.query)
|
|
159
|
+
void controller.toggle(entry.path);
|
|
160
|
+
else
|
|
161
|
+
focus(entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : undefined);
|
|
162
|
+
break;
|
|
163
|
+
case 'Enter':
|
|
164
|
+
case ' ':
|
|
165
|
+
if (entry.kind === 'directory')
|
|
166
|
+
void controller.toggle(entry.path);
|
|
167
|
+
else
|
|
168
|
+
onOpen?.(entry.path);
|
|
169
|
+
break;
|
|
170
|
+
default: return;
|
|
171
|
+
}
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
};
|
|
174
|
+
const listingStatus = (listing, directory, search = false) => {
|
|
175
|
+
if (!listing || listing.kind === 'loading')
|
|
176
|
+
return _jsx("li", { role: "none", children: _jsx("span", { role: "status", children: labels.loading }) });
|
|
177
|
+
if (listing.kind === 'error')
|
|
178
|
+
return _jsxs("li", { role: "none", children: [_jsx("span", { role: "alert", children: listing.reason }), " ", _jsx("button", { type: "button", onClick: () => { void (search ? controller.search(state.query) : controller.refresh(directory)); }, children: labels.retry })] });
|
|
179
|
+
return _jsxs(_Fragment, { children: [listing.entries.length === 0 && _jsx("li", { role: "none", children: _jsx("span", { children: search ? labels.noMatches : labels.empty }) }), listing.cursor !== undefined && _jsx("li", { role: "none", children: _jsx("button", { type: "button", onClick: () => { void (search ? controller.moreSearch() : controller.more(directory)); }, children: labels.loadMore }) })] });
|
|
180
|
+
};
|
|
181
|
+
const renderEntries = (directory, level) => _jsxs(_Fragment, { children: [(state.directories.get(directory)?.entries ?? []).map(entry => renderEntry(entry, level)), listingStatus(state.directories.get(directory), directory)] });
|
|
182
|
+
const renderEntry = (entry, level) => {
|
|
183
|
+
const folder = entry.kind === 'directory';
|
|
184
|
+
// A search result shows its name, with its folder beside it in muted text.
|
|
185
|
+
const name = entry.path.split('/').at(-1);
|
|
186
|
+
const parent = state.query && entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : '';
|
|
187
|
+
const button = _jsxs(SidebarMenuButton, { tabIndex: -1, title: entry.path, isActive: selectedPath === entry.path, className: "min-w-0 flex-1 data-[active=true]:bg-transparent", onClick: () => { focus(entry.path); if (!folder)
|
|
188
|
+
onOpen?.(entry.path); }, children: [folder ? _jsxs(_Fragment, { children: [_jsx(ChevronRight, { className: "transition-transform", "aria-hidden": "true" }), _jsx(Folder, { "aria-hidden": "true" })] }) : _jsx(File, { "aria-hidden": "true" }), _jsx("span", { className: "min-w-0 truncate", children: name }), parent && _jsx("span", { className: "min-w-0 shrink truncate text-xs text-muted-foreground", children: parent })] });
|
|
189
|
+
const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "aria-level": level, "aria-selected": selectedPath === entry.path, "aria-expanded": folder ? state.expanded.has(entry.path) : undefined, tabIndex: focusPath === entry.path ? 0 : -1, onFocus: event => { if (event.target === event.currentTarget)
|
|
190
|
+
setFocused(entry.path); }, onKeyDown: event => { if (event.target === event.currentTarget)
|
|
191
|
+
keyboard(event, entry); }, className: "group/collapsible rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring [&[data-state=open]>div>button>svg:first-child]:rotate-90", children: [_jsxs("div", { className: "flex items-center gap-1", children: [folder ? _jsx(Collapsible.Trigger, { asChild: true, children: button }) : button, !folder && history && _jsx("button", { type: "button", "aria-label": labels.historyOf(entry.path), className: "px-2 py-1 text-xs", onClick: () => setHistoryPath(entry.path), children: labels.history })] }), folder && !state.query && _jsx(Collapsible.Content, { asChild: true, children: _jsx(SidebarMenuSub, { role: "group", children: renderEntries(entry.path, level + 1) }) })] }, entry.path);
|
|
192
|
+
return folder ? _jsx(Collapsible.Root, { asChild: true, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); }, children: item }, entry.path) : item;
|
|
193
|
+
};
|
|
194
|
+
return _jsxs("section", { "data-boring": "file-tree", className: className, "aria-label": labels.region, children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 p-2", children: [_jsx("input", { type: "search", "aria-label": labels.search, placeholder: labels.searchPlaceholder, className: "min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-sm", value: state.query, onChange: event => { void controller.search(event.currentTarget.value); } }), _jsx("button", { type: "button", onClick: () => { void (state.query ? controller.search(state.query) : controller.refresh()); }, children: labels.refresh }), upload && _jsxs("label", { className: "cursor-pointer text-sm", children: [labels.upload, _jsx("input", { "aria-label": labels.uploadFiles, type: "file", multiple: true, className: "sr-only", onChange: event => {
|
|
195
|
+
const files = Array.from(event.currentTarget.files ?? []);
|
|
196
|
+
event.currentTarget.value = '';
|
|
197
|
+
setUploadError('');
|
|
198
|
+
void (async () => {
|
|
199
|
+
// One refused or unreadable file does not stop the others; every refusal is reported.
|
|
200
|
+
const errors = [];
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
const path = uploadDirectory ? `${uploadDirectory}/${file.name}` : file.name;
|
|
203
|
+
try {
|
|
204
|
+
await controller.upload({ path, bytes: new Uint8Array(await file.arrayBuffer()), mediaType: file.type || 'application/octet-stream' });
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
errors.push(error instanceof Error ? error.message : labels.uploadFailed(file.name));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (errors.length)
|
|
211
|
+
setUploadError(errors.join(' '));
|
|
212
|
+
})();
|
|
213
|
+
} })] })] }), uploadError && _jsx("p", { role: "alert", children: uploadError }), _jsx(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "m-0 list-none p-2", children: state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1) }), state.uploads.length > 0 && _jsx("ul", { "aria-label": labels.uploads, className: "list-none p-2 text-sm", children: state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [_jsxs("span", { children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.kind === 'partial' ? labels.unconfirmed : upload.state.result.reason] }), upload.state.kind === 'settled' && upload.state.result.kind === 'unknown' && _jsx("button", { type: "button", onClick: () => { void controller.reconcile(upload.operationId).catch(error => setUploadError(error instanceof Error ? error.message : labels.uploadStatusUnavailable)); }, children: labels.checkUpload })] }, upload.operationId)) }), history && historyPath !== undefined && _jsx(FileHistory, { controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
|
|
214
|
+
}
|
|
215
|
+
function FileHistory({ controller, path, labels, reload, onClose }) {
|
|
216
|
+
const [versions, setVersions] = useState();
|
|
217
|
+
const [failure, setFailure] = useState('');
|
|
218
|
+
const [selected, setSelected] = useState();
|
|
219
|
+
const [preview, setPreview] = useState();
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
const abort = new AbortController();
|
|
222
|
+
void controller.history(path, abort.signal).then(value => { if (!abort.signal.aborted)
|
|
223
|
+
setVersions(value); }, error => { if (!abort.signal.aborted)
|
|
224
|
+
setFailure(error instanceof Error ? error.message : labels.historyUnavailable); });
|
|
225
|
+
return () => abort.abort();
|
|
226
|
+
}, [controller, path, reload]);
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (selected === undefined)
|
|
229
|
+
return;
|
|
230
|
+
const abort = new AbortController();
|
|
231
|
+
setPreview(undefined);
|
|
232
|
+
setFailure('');
|
|
233
|
+
void controller.revisionProvider.read({ target: controller.revisionProvider.locate(path), revision: { kind: 'exact', value: selected } }, abort.signal).then(result => {
|
|
234
|
+
if (abort.signal.aborted)
|
|
235
|
+
return;
|
|
236
|
+
const target = controller.revisionProvider.locate(path);
|
|
237
|
+
if (result.kind === 'available' && result.snapshot.ref.revision === selected
|
|
238
|
+
&& result.snapshot.ref.resource.providerId === target.resource.providerId && result.snapshot.ref.resource.path === target.resource.path
|
|
239
|
+
&& result.snapshot.ref.view.kind === target.view.kind && (target.view.kind !== 'working'
|
|
240
|
+
|| (result.snapshot.ref.view.kind === 'working' && result.snapshot.ref.view.viewId === target.view.viewId)))
|
|
241
|
+
setPreview(result.snapshot);
|
|
242
|
+
else
|
|
243
|
+
setFailure(result.kind === 'denied' || result.kind === 'unavailable' ? result.reason : labels.revisionUnavailable);
|
|
244
|
+
}, () => { if (!abort.signal.aborted)
|
|
245
|
+
setFailure(labels.revisionUnavailable); });
|
|
246
|
+
return () => abort.abort();
|
|
247
|
+
}, [controller, path, selected]);
|
|
248
|
+
let text;
|
|
249
|
+
if (preview) {
|
|
250
|
+
try {
|
|
251
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(preview.bytes);
|
|
252
|
+
}
|
|
253
|
+
catch { /* Binary history remains a byte-qualified read-only preview. */ }
|
|
254
|
+
}
|
|
255
|
+
return _jsxs("section", { "aria-label": labels.historyOf(path), className: "border-t border-border p-2 text-sm", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("h3", { className: "font-medium", children: labels.historyOf(path) }), _jsx("button", { type: "button", onClick: onClose, children: labels.closeHistory })] }), failure && _jsx("p", { role: "alert", children: failure }), !versions && !failure && _jsx("p", { role: "status", children: labels.loadingHistory }), versions?.length === 0 && _jsx("p", { children: labels.noHistory }), _jsx("ul", { className: "m-0 list-none p-0", children: versions?.map(version => _jsx("li", { children: _jsxs("button", { type: "button", "aria-pressed": selected === version.revision, onClick: () => setSelected(version.revision), children: [version.savedAt ? new Date(version.savedAt).toLocaleString() : labels.earlierVersion, " \u00B7 ", version.revision.slice(0, 8)] }) }, version.revision)) }), preview && _jsxs("div", { "data-testid": "file-history-preview", "data-revision": preview.ref.revision, children: [_jsx("p", { children: labels.readOnlyRevision(preview.bytes.byteLength) }), text === undefined ? _jsx("p", { children: labels.binaryFile(preview.mediaType) }) : _jsx("pre", { className: "max-h-80 overflow-auto whitespace-pre-wrap", children: text })] })] });
|
|
256
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { PublicationResult, ResourceRef } from '@hachej/boring-files';
|
|
2
|
+
import type { FileEntry, RevisionProvider, SavedRevision } from '@hachej/boring-files/revision';
|
|
3
|
+
export type FileListing = {
|
|
4
|
+
readonly kind: 'loading';
|
|
5
|
+
readonly entries: readonly FileEntry[];
|
|
6
|
+
} | {
|
|
7
|
+
readonly kind: 'ready';
|
|
8
|
+
readonly entries: readonly FileEntry[];
|
|
9
|
+
readonly cursor?: string;
|
|
10
|
+
} | {
|
|
11
|
+
readonly kind: 'error';
|
|
12
|
+
readonly entries: readonly FileEntry[];
|
|
13
|
+
readonly reason: string;
|
|
14
|
+
};
|
|
15
|
+
export interface FileUpload {
|
|
16
|
+
readonly operationId: string;
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly state: {
|
|
19
|
+
readonly kind: 'pending';
|
|
20
|
+
} | {
|
|
21
|
+
readonly kind: 'settled';
|
|
22
|
+
readonly result: PublicationResult;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export interface FileTreeState {
|
|
26
|
+
readonly lifecycle: 'active' | 'disposed';
|
|
27
|
+
readonly directories: ReadonlyMap<string, FileListing>;
|
|
28
|
+
readonly expanded: ReadonlySet<string>;
|
|
29
|
+
readonly query: string;
|
|
30
|
+
readonly search: FileListing;
|
|
31
|
+
readonly uploads: readonly FileUpload[];
|
|
32
|
+
}
|
|
33
|
+
export interface FileTreeController {
|
|
34
|
+
readonly revisionProvider: RevisionProvider;
|
|
35
|
+
readonly getSnapshot: () => FileTreeState;
|
|
36
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
37
|
+
readonly refresh: (directory?: string) => Promise<void>;
|
|
38
|
+
readonly more: (directory?: string) => Promise<void>;
|
|
39
|
+
readonly toggle: (directory: string) => Promise<void>;
|
|
40
|
+
readonly search: (query: string) => Promise<void>;
|
|
41
|
+
readonly moreSearch: () => Promise<void>;
|
|
42
|
+
readonly upload: (input: {
|
|
43
|
+
readonly path: string;
|
|
44
|
+
readonly bytes: Uint8Array;
|
|
45
|
+
readonly mediaType?: string;
|
|
46
|
+
readonly replace?: ResourceRef;
|
|
47
|
+
readonly signal?: AbortSignal;
|
|
48
|
+
}) => Promise<FileUpload>;
|
|
49
|
+
readonly reconcile: (operationId: string) => Promise<FileUpload>;
|
|
50
|
+
readonly history: (path: string, signal?: AbortSignal) => Promise<readonly SavedRevision[]>;
|
|
51
|
+
readonly dispose: () => void;
|
|
52
|
+
}
|
|
53
|
+
/** Browsing state belongs to one borrowed binding. Disposal fences late results; it never closes the workspace. */
|
|
54
|
+
export declare function createFileTreeController({ revisionProvider }: {
|
|
55
|
+
readonly revisionProvider: RevisionProvider;
|
|
56
|
+
}): FileTreeController;
|
|
57
|
+
//# sourceMappingURL=file-tree.d.ts.map
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { parsePublicationResult, PublicationNotDispatchedError, publicationDigest, publicationSnapshot } from '@hachej/boring-files/publication';
|
|
2
|
+
import { randomUUID } from '@hachej/boring-files/platform';
|
|
3
|
+
const message = (error) => error instanceof Error ? error.message : 'Files could not be loaded';
|
|
4
|
+
const empty = { kind: 'ready', entries: [] };
|
|
5
|
+
const parentOf = (path) => path.slice(0, Math.max(0, path.lastIndexOf('/')));
|
|
6
|
+
const sameBytes = (left, right) => left.length === right.length && left.every((byte, index) => byte === right[index]);
|
|
7
|
+
const sameTarget = (left, right) => left.resource.providerId === right.resource.providerId && left.resource.path === right.resource.path
|
|
8
|
+
&& left.view.kind === right.view.kind && (left.view.kind !== 'working' || (right.view.kind === 'working' && left.view.viewId === right.view.viewId));
|
|
9
|
+
/** Browsing state belongs to one borrowed binding. Disposal fences late results; it never closes the workspace. */
|
|
10
|
+
export function createFileTreeController({ revisionProvider }) {
|
|
11
|
+
let state = { lifecycle: 'active', directories: new Map(), expanded: new Set(), query: '', search: empty, uploads: [] };
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
const lifetime = new AbortController();
|
|
14
|
+
const loads = new Map();
|
|
15
|
+
const attempts = new Map();
|
|
16
|
+
const update = (next) => { if (state.lifecycle === 'active') {
|
|
17
|
+
state = next;
|
|
18
|
+
for (const listener of [...listeners]) {
|
|
19
|
+
try {
|
|
20
|
+
listener();
|
|
21
|
+
}
|
|
22
|
+
catch { /* A subscriber cannot interrupt publication or other subscribers. */ }
|
|
23
|
+
}
|
|
24
|
+
} };
|
|
25
|
+
const active = () => { if (state.lifecycle !== 'active')
|
|
26
|
+
throw new Error('The file tree is disposed'); };
|
|
27
|
+
const directory = (path, listing) => update({ ...state, directories: new Map(state.directories).set(path, listing) });
|
|
28
|
+
async function load(path, append, search = false) {
|
|
29
|
+
active();
|
|
30
|
+
const key = search ? 'search' : `directory:${path}`;
|
|
31
|
+
const previous = search ? state.search : state.directories.get(path) ?? empty;
|
|
32
|
+
const cursor = append && previous.kind === 'ready' ? previous.cursor : undefined;
|
|
33
|
+
if (append && cursor === undefined)
|
|
34
|
+
return;
|
|
35
|
+
loads.get(key)?.abort();
|
|
36
|
+
const abort = new AbortController();
|
|
37
|
+
loads.set(key, abort);
|
|
38
|
+
const signal = AbortSignal.any([abort.signal, lifetime.signal]);
|
|
39
|
+
const query = state.query;
|
|
40
|
+
const set = (value) => search ? update({ ...state, search: value }) : directory(path, value);
|
|
41
|
+
set({ kind: 'loading', entries: previous.entries });
|
|
42
|
+
try {
|
|
43
|
+
const page = await (search ? revisionProvider.search({ query, ...(cursor ? { cursor } : {}) }, signal)
|
|
44
|
+
: revisionProvider.list({ directory: path, ...(cursor ? { cursor } : {}) }, signal));
|
|
45
|
+
if (signal.aborted || loads.get(key) !== abort)
|
|
46
|
+
return;
|
|
47
|
+
const entries = append ? [...new Map([...previous.entries, ...page.entries].map(entry => [entry.path, entry])).values()] : [...page.entries];
|
|
48
|
+
set({ kind: 'ready', entries, ...(page.cursor === undefined ? {} : { cursor: page.cursor }) });
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (!signal.aborted && loads.get(key) === abort)
|
|
52
|
+
set({ kind: 'error', entries: previous.entries, reason: message(error) });
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
if (loads.get(key) === abort)
|
|
56
|
+
loads.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const refresh = (path = '') => load(path, false);
|
|
60
|
+
function uploadState(operationId, result) {
|
|
61
|
+
const attempt = attempts.get(operationId);
|
|
62
|
+
if (!attempt)
|
|
63
|
+
throw new Error('Unknown upload operation');
|
|
64
|
+
const current = { operationId, path: attempt.current.path, state: { kind: 'settled', result } };
|
|
65
|
+
attempt.current = current;
|
|
66
|
+
update({ ...state, uploads: state.uploads.map(item => item.operationId === operationId ? current : item) });
|
|
67
|
+
if (result.kind === 'committed' && state.lifecycle === 'active') {
|
|
68
|
+
let path = parentOf(current.path);
|
|
69
|
+
const parents = new Set(['', path]);
|
|
70
|
+
while (path.includes('/')) {
|
|
71
|
+
path = parentOf(path);
|
|
72
|
+
parents.add(path);
|
|
73
|
+
}
|
|
74
|
+
update({ ...state, expanded: new Set([...state.expanded, ...[...parents].filter(Boolean)]) });
|
|
75
|
+
for (const parent of parents)
|
|
76
|
+
void refresh(parent);
|
|
77
|
+
}
|
|
78
|
+
return current;
|
|
79
|
+
}
|
|
80
|
+
const unknown = (operationId, reason) => ({ kind: 'unknown', operationId, reason });
|
|
81
|
+
function checked(operationId, input) {
|
|
82
|
+
const attempt = attempts.get(operationId);
|
|
83
|
+
if (!attempt)
|
|
84
|
+
throw new Error('Unknown upload operation');
|
|
85
|
+
let result;
|
|
86
|
+
try {
|
|
87
|
+
result = parsePublicationResult(input);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return unknown(operationId, 'The upload acknowledgement was invalid; check its status');
|
|
91
|
+
}
|
|
92
|
+
if (result.kind === 'partial' || (result.kind === 'unknown' && result.operationId !== operationId))
|
|
93
|
+
return unknown(operationId, 'The upload acknowledgement did not match its operation');
|
|
94
|
+
if (result.kind !== 'committed')
|
|
95
|
+
return result;
|
|
96
|
+
const receipt = result.receipt, change = receipt.changes[0], expected = attempt.request.changes[0], identity = revisionProvider.identity;
|
|
97
|
+
if (receipt.operationId !== operationId || receipt.argumentDigest !== attempt.digest || !receipt.evidenceRef
|
|
98
|
+
|| receipt.principalId !== identity.principalId || receipt.scopeId !== identity.scopeId || receipt.initiatorId !== identity.initiatorId
|
|
99
|
+
|| receipt.changes.length !== 1 || !change?.after || !sameTarget(change.after, expected.target)
|
|
100
|
+
|| (expected.kind === 'create' ? change.kind !== 'create' || change.before !== null
|
|
101
|
+
: expected.kind !== 'replace' || change.kind !== 'replace' || !change.before || !sameTarget(change.before, expected.target) || change.before.revision !== expected.target.revision)) {
|
|
102
|
+
return unknown(operationId, 'The upload receipt did not match its request');
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
const controller = {
|
|
107
|
+
revisionProvider, getSnapshot: () => state,
|
|
108
|
+
subscribe: listener => { if (state.lifecycle === 'disposed')
|
|
109
|
+
return () => { }; listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
110
|
+
refresh, more: (path = '') => load(path, true),
|
|
111
|
+
toggle: async (path) => {
|
|
112
|
+
active();
|
|
113
|
+
const expanded = new Set(state.expanded);
|
|
114
|
+
if (expanded.has(path))
|
|
115
|
+
expanded.delete(path);
|
|
116
|
+
else
|
|
117
|
+
expanded.add(path);
|
|
118
|
+
update({ ...state, expanded });
|
|
119
|
+
if (expanded.has(path) && !state.directories.has(path))
|
|
120
|
+
await refresh(path);
|
|
121
|
+
},
|
|
122
|
+
search: async (query) => {
|
|
123
|
+
active();
|
|
124
|
+
loads.get('search')?.abort();
|
|
125
|
+
update({ ...state, query, search: empty });
|
|
126
|
+
if (query)
|
|
127
|
+
await load('', false, true);
|
|
128
|
+
},
|
|
129
|
+
moreSearch: () => load('', true, true),
|
|
130
|
+
upload: async (input) => {
|
|
131
|
+
active();
|
|
132
|
+
// An unresolved upload of the same path answers a new one, so nothing is published twice: an unconfirmed one must be checked
|
|
133
|
+
// first, and a pending one answers a repeat of its bytes (a double click or drop). Different bytes while one is still pending
|
|
134
|
+
// are refused outright: answering with the earlier upload would report them as saved when they were not.
|
|
135
|
+
const bytes = input.bytes.slice();
|
|
136
|
+
const unresolved = () => {
|
|
137
|
+
const attempt = [...attempts.values()].find(item => item.current.path === input.path
|
|
138
|
+
&& (item.current.state.kind === 'pending' || item.current.state.result.kind === 'unknown'));
|
|
139
|
+
if (attempt?.current.state.kind === 'pending' && !sameBytes(attempt.bytes, bytes))
|
|
140
|
+
throw new Error(`${input.path} is already being uploaded; wait for it to finish`);
|
|
141
|
+
return attempt?.current;
|
|
142
|
+
};
|
|
143
|
+
const earlier = unresolved();
|
|
144
|
+
if (earlier)
|
|
145
|
+
return earlier;
|
|
146
|
+
const operationId = randomUUID();
|
|
147
|
+
const target = revisionProvider.locate(input.path);
|
|
148
|
+
if (input.replace && !sameTarget(input.replace, target))
|
|
149
|
+
throw new TypeError('The replacement revision belongs to another file');
|
|
150
|
+
const request = publicationSnapshot({ operationId, atomicity: 'all-or-nothing', changes: [input.replace
|
|
151
|
+
? { kind: 'replace', target: input.replace, bytes, mediaType: input.mediaType ?? 'application/octet-stream' }
|
|
152
|
+
: { kind: 'create', target, expected: { kind: 'absent' }, bytes, mediaType: input.mediaType ?? 'application/octet-stream' }] });
|
|
153
|
+
const current = { operationId, path: input.path, state: { kind: 'pending' } };
|
|
154
|
+
const digest = await publicationDigest(request);
|
|
155
|
+
active();
|
|
156
|
+
const concurrent = unresolved();
|
|
157
|
+
if (concurrent)
|
|
158
|
+
return concurrent;
|
|
159
|
+
attempts.set(operationId, { request, digest, bytes, current });
|
|
160
|
+
update({ ...state, uploads: [...state.uploads, current] });
|
|
161
|
+
const signal = input.signal ? AbortSignal.any([input.signal, lifetime.signal]) : lifetime.signal;
|
|
162
|
+
if (signal.aborted)
|
|
163
|
+
return uploadState(operationId, { kind: 'unavailable', reason: 'Upload cancelled before publication' });
|
|
164
|
+
try {
|
|
165
|
+
return uploadState(operationId, checked(operationId, await revisionProvider.publish(request, signal)));
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return uploadState(operationId, error instanceof PublicationNotDispatchedError && error.operationId === operationId
|
|
169
|
+
? { kind: 'unavailable', reason: 'Upload was not sent' }
|
|
170
|
+
: unknown(operationId, 'Upload outcome is unconfirmed; check its status before trying again'));
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
reconcile: operationId => {
|
|
174
|
+
active();
|
|
175
|
+
const attempt = attempts.get(operationId);
|
|
176
|
+
if (!attempt)
|
|
177
|
+
return Promise.reject(new Error('Unknown upload operation'));
|
|
178
|
+
if (attempt.current.state.kind === 'pending')
|
|
179
|
+
return Promise.reject(new Error('Upload is still in progress'));
|
|
180
|
+
if (attempt.current.state.result.kind !== 'unknown')
|
|
181
|
+
return Promise.resolve(attempt.current);
|
|
182
|
+
if (attempt.reconciling)
|
|
183
|
+
return attempt.reconciling;
|
|
184
|
+
const run = async () => {
|
|
185
|
+
try {
|
|
186
|
+
const result = await revisionProvider.lookup(operationId, lifetime.signal);
|
|
187
|
+
return uploadState(operationId, result.kind === 'not-found' ? unknown(operationId, 'No retained receipt. Keep the local file; do not replay this upload blindly.') : checked(operationId, result));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return uploadState(operationId, unknown(operationId, 'The upload status could not be confirmed'));
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
delete attempt.reconciling;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
return attempt.reconciling = run();
|
|
197
|
+
},
|
|
198
|
+
history: (path, signal) => { active(); return revisionProvider.history(path, signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal); },
|
|
199
|
+
dispose: () => { if (state.lifecycle === 'disposed')
|
|
200
|
+
return; lifetime.abort(); for (const load of loads.values())
|
|
201
|
+
load.abort(); loads.clear(); state = { ...state, lifecycle: 'disposed' }; listeners.clear(); },
|
|
202
|
+
};
|
|
203
|
+
return controller;
|
|
204
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const formatting = new Set(['p', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code', 'strong', 'em', 'b', 'i', 'u', 's', 'small', 'sub', 'sup', 'kbd', 'samp', 'var', 'mark', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'caption', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'section', 'article', 'header', 'footer', 'main', 'aside', 'figure', 'figcaption', 'br', 'hr']);
|
|
2
|
+
const htmlNamespace = 'http://www.w3.org/1999/xhtml';
|
|
3
|
+
const prefix = '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; base-uri \'none\'; form-action \'none\'"><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta name="format-detection" content="telephone=no,email=no,address=no"><title>HTML preview</title></head><body>';
|
|
4
|
+
export function passiveHtml(source, document) {
|
|
5
|
+
if (source.length > 262_144)
|
|
6
|
+
throw new RangeError('HTML preview source exceeds its limit');
|
|
7
|
+
const template = document.createElement('template');
|
|
8
|
+
template.innerHTML = source;
|
|
9
|
+
let nodes = 0, length = prefix.length;
|
|
10
|
+
const output = [prefix];
|
|
11
|
+
function emit(text) {
|
|
12
|
+
length += text.length;
|
|
13
|
+
if (length > 1_048_576)
|
|
14
|
+
throw new RangeError('HTML preview output exceeds its limit');
|
|
15
|
+
output.push(text);
|
|
16
|
+
}
|
|
17
|
+
function visit(node, depth) {
|
|
18
|
+
if (++nodes > 20_000 || depth > 128)
|
|
19
|
+
throw new RangeError('HTML preview structure exceeds its limit');
|
|
20
|
+
if (node.nodeType === 3) {
|
|
21
|
+
emit((node.textContent ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (node.nodeType !== 1 || !('namespaceURI' in node) || node.namespaceURI !== htmlNamespace || !('localName' in node) || typeof node.localName !== 'string')
|
|
25
|
+
return;
|
|
26
|
+
if (node.localName === 'html' || node.localName === 'head' || node.localName === 'body') {
|
|
27
|
+
for (let child = node.firstChild; child; child = child.nextSibling)
|
|
28
|
+
visit(child, depth + 1);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const tag = node.localName === 'a' ? 'span' : node.localName;
|
|
32
|
+
if (!formatting.has(tag))
|
|
33
|
+
return;
|
|
34
|
+
emit('<' + tag + '>');
|
|
35
|
+
if (tag === 'br' || tag === 'hr')
|
|
36
|
+
return;
|
|
37
|
+
if (tag === 'pre')
|
|
38
|
+
emit('\n');
|
|
39
|
+
for (let child = node.firstChild; child; child = child.nextSibling)
|
|
40
|
+
visit(child, depth + 1);
|
|
41
|
+
emit('</' + tag + '>');
|
|
42
|
+
}
|
|
43
|
+
for (let child = template.content.firstChild; child; child = child.nextSibling)
|
|
44
|
+
visit(child, 0);
|
|
45
|
+
emit('</body></html>');
|
|
46
|
+
return output.join('');
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HtmlController } from './html.js';
|
|
2
|
+
export interface HtmlViewerProps {
|
|
3
|
+
readonly controller: HtmlController;
|
|
4
|
+
readonly title?: string;
|
|
5
|
+
readonly className?: string;
|
|
6
|
+
/** `false` omits the built-in header and toolbar (mode, Save, Refresh, reconcile, discard) so a host bar such as the viewer frame supplies them. */
|
|
7
|
+
readonly header?: boolean;
|
|
8
|
+
/** Controlled mode; without it the viewer keeps its own and starts on the preview. */
|
|
9
|
+
readonly mode?: 'source' | 'preview';
|
|
10
|
+
readonly onModeChange?: (mode: 'source' | 'preview') => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function HtmlViewer(props: HtmlViewerProps): import("react").JSX.Element;
|
|
13
|
+
//# sourceMappingURL=html-viewer.d.ts.map
|