@k2b/cloud 0.23.0 → 0.25.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/package.json +3 -2
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +12 -3
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +1 -1
- package/src/_internal/registry.ts +78 -3
- package/src/access/PermissionEditor.tsx +32 -11
- package/src/access/managers.ts +32 -0
- package/src/access/messages.ts +6 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +65 -20
- package/src/ai/client/transport.ts +5 -5
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/memory-learning.ts +7 -12
- package/src/ai/memory-workflow-evidence.ts +10 -2
- package/src/ai/migrate.ts +80 -18
- package/src/ai/model-access.ts +4 -2
- package/src/ai/prefs.ts +19 -5
- package/src/ai/projects.ts +5 -2
- package/src/ai/runtime.ts +1 -6
- package/src/ai/skills.ts +5 -2
- package/src/ai/solid.ts +0 -5
- package/src/ai/store.ts +14 -0
- package/src/api/help.ts +2 -2
- package/src/api/pwa-phone.ts +10 -3
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- package/src/browser/FileChooser.tsx +600 -0
- package/src/browser/choose-files.browser-harness.tsx +32 -0
- package/src/browser/choose-files.tsx +70 -0
- package/src/browser/file-chooser-messages.ts +86 -0
- package/src/browser/file-providers.ts +217 -0
- package/src/browser/files.tsx +23 -0
- package/src/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/cli/admin/account-administration.ts +2 -2
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +1 -0
- package/src/events/live-engine.ts +11 -7
- package/src/events/live.ts +32 -32
- package/src/server/help.ts +3 -3
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +44 -11
- package/src/server/services/index.ts +1 -0
- package/src/services/index.ts +1 -1
- package/src/services/outbox.ts +29 -115
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +74 -8
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -14
- package/src/ssr/PwaLayout.tsx +2 -1
- package/src/ssr/platform-messages.ts +2 -2
- package/src/styles/file-chooser.css +169 -0
- package/src/styles/global.css +1 -0
- package/src/styles/resource-search.css +19 -0
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- package/src/shared/markdown/extensions/info-blocks.ts +0 -79
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import { fileIcons, text } from "@k2b/stdlib";
|
|
2
|
+
import { timed } from "@k2b/stdlib/solid";
|
|
3
|
+
import {
|
|
4
|
+
announce,
|
|
5
|
+
Button,
|
|
6
|
+
createCollectionSelection,
|
|
7
|
+
FileGrid,
|
|
8
|
+
Format,
|
|
9
|
+
IconButton,
|
|
10
|
+
PanelDialog,
|
|
11
|
+
Placeholder,
|
|
12
|
+
ProgressBar,
|
|
13
|
+
StatusBadge,
|
|
14
|
+
type StatusTone,
|
|
15
|
+
TextInput,
|
|
16
|
+
useLocale,
|
|
17
|
+
} from "@k2b/ui";
|
|
18
|
+
import { batch, createMemo, createSignal, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js";
|
|
19
|
+
import { createStore } from "solid-js/store";
|
|
20
|
+
import type { FileProviderEntry, FileProviderTag } from "../contracts/file-provider";
|
|
21
|
+
import { fileChooserMessages } from "./file-chooser-messages";
|
|
22
|
+
import {
|
|
23
|
+
type EntryProblem,
|
|
24
|
+
eachLimited,
|
|
25
|
+
entryProblem,
|
|
26
|
+
FILE_PROVIDER_PARALLEL_READS,
|
|
27
|
+
type FileProviderCaller,
|
|
28
|
+
FileProviderError,
|
|
29
|
+
type FileProviderSource,
|
|
30
|
+
listProviderFolder,
|
|
31
|
+
providerIcon,
|
|
32
|
+
readProviderFile,
|
|
33
|
+
} from "./file-providers";
|
|
34
|
+
|
|
35
|
+
export type ChooseFilesOptions = {
|
|
36
|
+
/** `<input accept>` syntax, for example `"image/*,.pdf"`. */
|
|
37
|
+
accept?: string;
|
|
38
|
+
/** Allow choosing several files. Defaults to `false`. */
|
|
39
|
+
multiple?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The most bytes the consumer takes from providers in one choice: no single file and no selection together goes
|
|
42
|
+
* above it, so it also bounds what the browser holds. The provider's own read limit applies to each file as well.
|
|
43
|
+
*/
|
|
44
|
+
maxBytes?: number;
|
|
45
|
+
/** Closes the chooser, or stops waiting for the device's dialog, and resolves `[]` when aborted. */
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type FileProviderList = { state: "loading" } | { state: "ready"; providers: readonly FileProviderSource[] } | { state: "error" };
|
|
50
|
+
|
|
51
|
+
type FileEntry = Extract<FileProviderEntry, { kind: "file" }>;
|
|
52
|
+
type Crumb = { id?: string; name: string };
|
|
53
|
+
type Location = { provider: FileProviderSource; trail: readonly Crumb[] } | null;
|
|
54
|
+
type SourceRow = { id: string; name: string; icon: string; provider?: FileProviderSource };
|
|
55
|
+
type Download = {
|
|
56
|
+
entry: FileEntry;
|
|
57
|
+
loaded: number;
|
|
58
|
+
total: number;
|
|
59
|
+
state: "waiting" | "loading" | "done" | "error";
|
|
60
|
+
file?: File;
|
|
61
|
+
/** Why a read was refused: the file changed after it was listed and no longer fits. */
|
|
62
|
+
reason?: EntryProblem;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Empty pages may still continue; the chooser follows at most this many of them before it shows "Load more". */
|
|
66
|
+
const EMPTY_PAGES_FOLLOWED = 5;
|
|
67
|
+
|
|
68
|
+
const TAG_TONES: Record<NonNullable<FileProviderTag["tone"]>, StatusTone> = {
|
|
69
|
+
neutral: "neutral",
|
|
70
|
+
info: "info",
|
|
71
|
+
success: "ok",
|
|
72
|
+
warning: "warning",
|
|
73
|
+
danger: "error",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const localTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
77
|
+
const entryIcon = (entry: FileProviderEntry) =>
|
|
78
|
+
providerIcon(entry.icon, `ti ${fileIcons.getFileIcon({ name: entry.name, type: entry.kind === "folder" ? "directory" : "file" })}`);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The shared file chooser: "This device" first, then every provider; inside a provider, its folders page by page.
|
|
82
|
+
* It resolves the chosen files, read through the providers' streams, or `[]`.
|
|
83
|
+
*/
|
|
84
|
+
export function FileChooser(props: {
|
|
85
|
+
options: ChooseFilesOptions;
|
|
86
|
+
providers: () => FileProviderList;
|
|
87
|
+
retryProviders: () => void;
|
|
88
|
+
caller: () => FileProviderCaller;
|
|
89
|
+
close: (files: File[]) => void;
|
|
90
|
+
}): JSX.Element {
|
|
91
|
+
const locale = useLocale();
|
|
92
|
+
const t = () => fileChooserMessages.resolve([locale()]).t;
|
|
93
|
+
const multiple = props.options.multiple ?? false;
|
|
94
|
+
let deviceInput: HTMLInputElement | undefined;
|
|
95
|
+
let root: HTMLDivElement | undefined;
|
|
96
|
+
|
|
97
|
+
const [location, setLocation] = createSignal<Location>(null);
|
|
98
|
+
const [filter, setFilter] = createSignal("");
|
|
99
|
+
const [query, setQuery] = createSignal("");
|
|
100
|
+
const [items, setItems] = createSignal<FileProviderEntry[]>([]);
|
|
101
|
+
const [next, setNext] = createSignal<string | null>(null);
|
|
102
|
+
const [status, setStatus] = createSignal<"loading" | "ready" | "more" | "error">("ready");
|
|
103
|
+
const [error, setError] = createSignal<FileProviderError | null>(null);
|
|
104
|
+
const [downloads, setDownloads] = createStore<Download[]>([]);
|
|
105
|
+
const [downloading, setDownloading] = createSignal(false);
|
|
106
|
+
let request: AbortController | undefined;
|
|
107
|
+
let transfer: AbortController | undefined;
|
|
108
|
+
onCleanup(() => {
|
|
109
|
+
request?.abort();
|
|
110
|
+
transfer?.abort();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const budget = props.options.maxBytes ?? Number.POSITIVE_INFINITY;
|
|
114
|
+
/** The largest single file: the consumer's budget or the provider's read limit, whichever is smaller. */
|
|
115
|
+
const limit = () => Math.min(budget, location()?.provider.maxBytes ?? Number.POSITIVE_INFINITY);
|
|
116
|
+
const problem = (entry: FileProviderEntry): EntryProblem | undefined => entryProblem(entry, props.options.accept, limit());
|
|
117
|
+
|
|
118
|
+
// Rows keep their identity, so providers that arrive later do not re-create the row that has focus.
|
|
119
|
+
const device = createMemo<SourceRow>(() => ({ id: "device", name: t().device, icon: "ti ti-device-laptop" }));
|
|
120
|
+
const providerRows = new Map<FileProviderSource, SourceRow>();
|
|
121
|
+
const providerRow = (provider: FileProviderSource): SourceRow => {
|
|
122
|
+
const row = providerRows.get(provider) ?? { id: `app:${provider.appId}`, name: provider.name, icon: provider.icon, provider };
|
|
123
|
+
providerRows.set(provider, row);
|
|
124
|
+
return row;
|
|
125
|
+
};
|
|
126
|
+
const sources = createMemo<SourceRow[]>(() => {
|
|
127
|
+
const list = props.providers();
|
|
128
|
+
const rows = [device(), ...(list.state === "ready" ? list.providers.map(providerRow) : [])];
|
|
129
|
+
const needle = filter().trim().toLocaleLowerCase();
|
|
130
|
+
return needle ? rows.filter((row) => row.name.toLocaleLowerCase().includes(needle)) : rows;
|
|
131
|
+
});
|
|
132
|
+
// Sources open on activation and keep no selection, so Escape always closes the chooser from here.
|
|
133
|
+
const sourceSelection = createCollectionSelection({ ids: () => sources().map((row) => row.id), multiple: false, checklist: true });
|
|
134
|
+
// Files that do not fit stay in focus order, so the keyboard and screen readers reach the reason, but cannot be chosen.
|
|
135
|
+
const blocked = createMemo(() => new Set(items().flatMap((entry) => (problem(entry) ? [entry.id] : []))));
|
|
136
|
+
const selection = createCollectionSelection({
|
|
137
|
+
ids: () => items().map((entry) => entry.id),
|
|
138
|
+
isDisabled: (id) => blocked().has(id),
|
|
139
|
+
multiple,
|
|
140
|
+
checklist: true,
|
|
141
|
+
});
|
|
142
|
+
const chosen = createMemo(() =>
|
|
143
|
+
items().filter((entry): entry is FileEntry => entry.kind === "file" && selection.selected().has(entry.id)),
|
|
144
|
+
);
|
|
145
|
+
const overBudget = () => chosen().reduce((sum, entry) => sum + entry.size, 0) > budget;
|
|
146
|
+
|
|
147
|
+
const folderName = () => location()?.trail.at(-1)?.name ?? t().sources;
|
|
148
|
+
|
|
149
|
+
const load = async (more = false) => {
|
|
150
|
+
const current = location();
|
|
151
|
+
if (!current) return;
|
|
152
|
+
request?.abort();
|
|
153
|
+
const pending = new AbortController();
|
|
154
|
+
request = pending;
|
|
155
|
+
if (!more) {
|
|
156
|
+
batch(() => {
|
|
157
|
+
setItems([]);
|
|
158
|
+
setNext(null);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
setStatus(more ? "more" : "loading");
|
|
162
|
+
setError(null);
|
|
163
|
+
try {
|
|
164
|
+
let cursor = more ? (next() ?? undefined) : undefined;
|
|
165
|
+
let followed = 0;
|
|
166
|
+
for (;;) {
|
|
167
|
+
const page = await listProviderFolder(
|
|
168
|
+
current.provider,
|
|
169
|
+
{ parent: current.trail.at(-1)?.id, query: query() || undefined, cursor },
|
|
170
|
+
props.caller(),
|
|
171
|
+
pending.signal,
|
|
172
|
+
);
|
|
173
|
+
if (pending.signal.aborted) return;
|
|
174
|
+
batch(() => {
|
|
175
|
+
setItems((previous) => [...previous, ...page.items]);
|
|
176
|
+
setNext(page.next);
|
|
177
|
+
});
|
|
178
|
+
if (items().length > 0 || !page.next || ++followed >= EMPTY_PAGES_FOLLOWED) break;
|
|
179
|
+
cursor = page.next;
|
|
180
|
+
}
|
|
181
|
+
setStatus("ready");
|
|
182
|
+
if (!more) {
|
|
183
|
+
announce(t().folderAnnounced({ folder: folderName(), count: items().length, more: Boolean(next()) }));
|
|
184
|
+
refocus();
|
|
185
|
+
}
|
|
186
|
+
} catch (cause) {
|
|
187
|
+
if (pending.signal.aborted) return;
|
|
188
|
+
setError(
|
|
189
|
+
cause instanceof FileProviderError ? cause : new FileProviderError("INTERNAL", cause instanceof Error ? cause.message : "", 500),
|
|
190
|
+
);
|
|
191
|
+
setStatus(more ? "ready" : "error");
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** After navigation the activated row is gone; give focus to the first row so the keyboard continues there. */
|
|
196
|
+
const refocus = () => {
|
|
197
|
+
queueMicrotask(() => {
|
|
198
|
+
const active = document.activeElement;
|
|
199
|
+
if (active && root?.contains(active) && active.isConnected && active.tagName !== "DIALOG") return;
|
|
200
|
+
const first = location() ? items()[0]?.id : sources()[0]?.id;
|
|
201
|
+
if (first) (location() ? selection : sourceSelection).focus(first);
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const applyQuery = (value: string) => {
|
|
206
|
+
setQuery(value.trim());
|
|
207
|
+
void load();
|
|
208
|
+
};
|
|
209
|
+
const { debouncedFn: applyFilter, cancel: cancelFilter } = timed.debounce(applyQuery, 250);
|
|
210
|
+
const clearFilter = () => {
|
|
211
|
+
cancelFilter();
|
|
212
|
+
setFilter("");
|
|
213
|
+
if (location() && query()) applyQuery("");
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const navigate = (next: Location) => {
|
|
217
|
+
cancelFilter();
|
|
218
|
+
request?.abort();
|
|
219
|
+
batch(() => {
|
|
220
|
+
setFilter("");
|
|
221
|
+
setQuery("");
|
|
222
|
+
setLocation(next);
|
|
223
|
+
setItems([]);
|
|
224
|
+
setNext(null);
|
|
225
|
+
setError(null);
|
|
226
|
+
setStatus(next ? "loading" : "ready");
|
|
227
|
+
});
|
|
228
|
+
if (next) void load();
|
|
229
|
+
else refocus();
|
|
230
|
+
};
|
|
231
|
+
const openProvider = (provider: FileProviderSource) => navigate({ provider, trail: [{ name: provider.name }] });
|
|
232
|
+
const openFolder = (entry: FileProviderEntry) => {
|
|
233
|
+
const current = location();
|
|
234
|
+
if (current) navigate({ provider: current.provider, trail: [...current.trail, { id: entry.id, name: entry.name }] });
|
|
235
|
+
};
|
|
236
|
+
const up = () => {
|
|
237
|
+
const current = location();
|
|
238
|
+
if (!current) return;
|
|
239
|
+
navigate(current.trail.length > 1 ? { provider: current.provider, trail: current.trail.slice(0, -1) } : null);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const openSource = (row: SourceRow) => {
|
|
243
|
+
sourceSelection.clear();
|
|
244
|
+
// A click or key press on the row is the user activation the native picker needs.
|
|
245
|
+
if (!row.provider) deviceInput?.click();
|
|
246
|
+
else openProvider(row.provider);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const add = async () => {
|
|
250
|
+
const files = chosen();
|
|
251
|
+
if (files.length === 0 || overBudget() || (downloading() && !failed())) return;
|
|
252
|
+
const current = location();
|
|
253
|
+
if (!current) return;
|
|
254
|
+
transfer?.abort();
|
|
255
|
+
const pending = new AbortController();
|
|
256
|
+
transfer = pending;
|
|
257
|
+
// Only a retry of this transfer finds earlier downloads: Cancel clears them, and the location cannot change
|
|
258
|
+
// while a transfer is shown, so a finished file never stands in for another provider's file with the same ID.
|
|
259
|
+
const previous = new Map(downloads.filter((item) => item.state === "done").map((item) => [item.entry.id, item]));
|
|
260
|
+
setDownloads(files.map((entry) => previous.get(entry.id) ?? { entry, loaded: 0, total: entry.size, state: "waiting" }));
|
|
261
|
+
setDownloading(true);
|
|
262
|
+
queueMicrotask(() => root?.querySelector<HTMLButtonElement>("[data-file-chooser-cancel]")?.focus());
|
|
263
|
+
await eachLimited(
|
|
264
|
+
downloads.map((_, index) => index),
|
|
265
|
+
FILE_PROVIDER_PARALLEL_READS,
|
|
266
|
+
async (index) => {
|
|
267
|
+
if (pending.signal.aborted || downloads[index]!.state === "done") return;
|
|
268
|
+
setDownloads(index, { state: "loading", loaded: 0 });
|
|
269
|
+
try {
|
|
270
|
+
const file = await readProviderFile(current.provider, downloads[index]!.entry, {
|
|
271
|
+
...props.caller(),
|
|
272
|
+
maxBytes: limit(),
|
|
273
|
+
accept: props.options.accept,
|
|
274
|
+
signal: pending.signal,
|
|
275
|
+
onProgress: (loaded, total) => setDownloads(index, { loaded, total }),
|
|
276
|
+
});
|
|
277
|
+
if (!pending.signal.aborted) setDownloads(index, { state: "done", file });
|
|
278
|
+
} catch (cause) {
|
|
279
|
+
const code = cause instanceof FileProviderError ? cause.code : "";
|
|
280
|
+
const reason = code === "FILE_TOO_LARGE" ? "size" : code === "UNSUPPORTED_MEDIA_TYPE" ? "type" : undefined;
|
|
281
|
+
if (!pending.signal.aborted) setDownloads(index, { state: "error", reason });
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
if (pending.signal.aborted) return;
|
|
286
|
+
if (downloads.every((item) => item.state === "done")) props.close(downloads.map((item) => item.file!));
|
|
287
|
+
};
|
|
288
|
+
const stop = () => {
|
|
289
|
+
transfer?.abort();
|
|
290
|
+
batch(() => {
|
|
291
|
+
setDownloading(false);
|
|
292
|
+
setDownloads([]);
|
|
293
|
+
});
|
|
294
|
+
queueMicrotask(() => {
|
|
295
|
+
const first = chosen()[0]?.id;
|
|
296
|
+
if (first) selection.focus(first);
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
const failures = () => downloads.filter((item) => item.state === "error").length;
|
|
300
|
+
const failed = () => failures() > 0;
|
|
301
|
+
const ready = () => downloads.filter((item) => item.state === "done").length;
|
|
302
|
+
const progress = () => t().downloading({ done: ready(), total: downloads.length, failed: failures() });
|
|
303
|
+
|
|
304
|
+
const errorView = createMemo(() => {
|
|
305
|
+
const value = error();
|
|
306
|
+
if (!value) return null;
|
|
307
|
+
const app = location()?.provider.name ?? "";
|
|
308
|
+
if (value.status === 0) return { title: t().offline, description: t().offlineHint, retry: true };
|
|
309
|
+
if (value.status === 403) return { title: t().noAccess, description: t().backHint, retry: false };
|
|
310
|
+
if (value.status === 404) return { title: t().notFound, description: t().backHint, retry: false };
|
|
311
|
+
if (value.status >= 502 || value.code === "APP_UNAVAILABLE")
|
|
312
|
+
return { title: t().unavailable({ app }), description: t().unavailableHint, retry: true };
|
|
313
|
+
return { title: t().loadFailed, description: value.message || undefined, retry: true };
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const renderTags = (tags: readonly FileProviderTag[] | undefined) => (
|
|
317
|
+
<For each={tags ?? []}>
|
|
318
|
+
{(tag) => <StatusBadge tone={TAG_TONES[tag.tone ?? "neutral"]} icon={null} label={tag.label} class="cloud-file-chooser__tag" />}
|
|
319
|
+
</For>
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<PanelDialog>
|
|
324
|
+
{/* `display: contents`, so header, body, and footer stay the panel's own rows. */}
|
|
325
|
+
<div ref={root} class="cloud-file-chooser">
|
|
326
|
+
<PanelDialog.Header title={t().title({ multiple })} close={() => props.close([])} />
|
|
327
|
+
<div class="cloud-file-chooser__toolbar">
|
|
328
|
+
<nav class="cloud-file-chooser__crumbs" aria-label={t().breadcrumbs}>
|
|
329
|
+
<IconButton size="sm" variant="ghost" label={t().up} disabled={!location() || downloading()} onClick={up}>
|
|
330
|
+
<i class="ti ti-arrow-up" aria-hidden="true" />
|
|
331
|
+
</IconButton>
|
|
332
|
+
<ol>
|
|
333
|
+
<li>
|
|
334
|
+
<Button
|
|
335
|
+
size="sm"
|
|
336
|
+
variant={location() ? "text" : "subtle"}
|
|
337
|
+
aria-current={location() ? undefined : "location"}
|
|
338
|
+
disabled={downloading()}
|
|
339
|
+
onClick={() => navigate(null)}
|
|
340
|
+
>
|
|
341
|
+
{t().sources}
|
|
342
|
+
</Button>
|
|
343
|
+
</li>
|
|
344
|
+
<For each={location()?.trail ?? []}>
|
|
345
|
+
{(crumb, index) => {
|
|
346
|
+
const last = () => index() === (location()?.trail.length ?? 0) - 1;
|
|
347
|
+
return (
|
|
348
|
+
<li>
|
|
349
|
+
<span aria-hidden="true">/</span>
|
|
350
|
+
<Button
|
|
351
|
+
size="sm"
|
|
352
|
+
variant={last() ? "subtle" : "text"}
|
|
353
|
+
aria-current={last() ? "location" : undefined}
|
|
354
|
+
disabled={downloading()}
|
|
355
|
+
onClick={() => {
|
|
356
|
+
const current = location();
|
|
357
|
+
if (current && !last()) navigate({ provider: current.provider, trail: current.trail.slice(0, index() + 1) });
|
|
358
|
+
}}
|
|
359
|
+
>
|
|
360
|
+
{crumb.name}
|
|
361
|
+
</Button>
|
|
362
|
+
</li>
|
|
363
|
+
);
|
|
364
|
+
}}
|
|
365
|
+
</For>
|
|
366
|
+
</ol>
|
|
367
|
+
</nav>
|
|
368
|
+
<TextInput
|
|
369
|
+
class="cloud-file-chooser__filter"
|
|
370
|
+
type="search"
|
|
371
|
+
icon="ti ti-filter"
|
|
372
|
+
aria-label={t().filter}
|
|
373
|
+
placeholder={t().filter}
|
|
374
|
+
maxLength={200}
|
|
375
|
+
disabled={downloading()}
|
|
376
|
+
value={filter}
|
|
377
|
+
onValueChange={(value) => {
|
|
378
|
+
setFilter(value);
|
|
379
|
+
if (location()) applyFilter(value);
|
|
380
|
+
}}
|
|
381
|
+
clearable
|
|
382
|
+
onClear={clearFilter}
|
|
383
|
+
/>
|
|
384
|
+
</div>
|
|
385
|
+
<PanelDialog.Body>
|
|
386
|
+
<input
|
|
387
|
+
ref={deviceInput}
|
|
388
|
+
type="file"
|
|
389
|
+
hidden
|
|
390
|
+
accept={props.options.accept}
|
|
391
|
+
multiple={multiple}
|
|
392
|
+
onChange={(event) => {
|
|
393
|
+
const files = Array.from(event.currentTarget.files ?? []);
|
|
394
|
+
event.currentTarget.value = "";
|
|
395
|
+
if (files.length > 0) props.close(files);
|
|
396
|
+
}}
|
|
397
|
+
/>
|
|
398
|
+
<Switch>
|
|
399
|
+
<Match when={downloading()}>
|
|
400
|
+
<ul class="cloud-file-chooser__downloads" aria-label={progress()}>
|
|
401
|
+
<For each={downloads}>
|
|
402
|
+
{(item) => (
|
|
403
|
+
<li>
|
|
404
|
+
<div class="cloud-file-chooser__download-line">
|
|
405
|
+
<span class="cloud-file-chooser__download-name" title={item.entry.name}>
|
|
406
|
+
{item.entry.name}
|
|
407
|
+
</span>
|
|
408
|
+
<span class="cloud-file-chooser__download-state" data-state={item.state}>
|
|
409
|
+
<Switch fallback={<Format.Percent value={item.total ? item.loaded / item.total : 0} clamp />}>
|
|
410
|
+
<Match when={item.state === "waiting"}>{t().waiting}</Match>
|
|
411
|
+
<Match when={item.state === "error" && item.reason === "size"}>
|
|
412
|
+
{t().tooLarge({ limit: text.pprintBytes(limit(), { locale: locale() }) })}
|
|
413
|
+
</Match>
|
|
414
|
+
<Match when={item.state === "error" && item.reason === "type"}>{t().wrongType}</Match>
|
|
415
|
+
<Match when={item.state === "error"}>{t().readFailed}</Match>
|
|
416
|
+
</Switch>
|
|
417
|
+
</span>
|
|
418
|
+
</div>
|
|
419
|
+
<ProgressBar
|
|
420
|
+
size="xs"
|
|
421
|
+
tone={item.state === "error" ? "danger" : item.state === "done" ? "success" : "info"}
|
|
422
|
+
value={item.state === "done" ? 100 : item.total ? (item.loaded / item.total) * 100 : 0}
|
|
423
|
+
label={t().downloadProgress({ name: item.entry.name })}
|
|
424
|
+
/>
|
|
425
|
+
</li>
|
|
426
|
+
)}
|
|
427
|
+
</For>
|
|
428
|
+
</ul>
|
|
429
|
+
</Match>
|
|
430
|
+
<Match when={!location()}>
|
|
431
|
+
<FileGrid
|
|
432
|
+
rows={sources()}
|
|
433
|
+
getRowId={(row) => row.id}
|
|
434
|
+
selection={sourceSelection}
|
|
435
|
+
label={t().sourceList}
|
|
436
|
+
layout="list"
|
|
437
|
+
renderPreview={(row) => <i class={`${row.icon} cloud-file-chooser__icon`} aria-hidden="true" />}
|
|
438
|
+
renderLabel={(row) => row.name}
|
|
439
|
+
onRowClick={openSource}
|
|
440
|
+
onOpen={openSource}
|
|
441
|
+
/>
|
|
442
|
+
<Switch>
|
|
443
|
+
<Match when={props.providers().state === "loading"}>
|
|
444
|
+
<Placeholder state="loading" variant="inline" align="left" description={t().providersLoading} />
|
|
445
|
+
</Match>
|
|
446
|
+
<Match when={props.providers().state === "error"}>
|
|
447
|
+
<Placeholder
|
|
448
|
+
state="error"
|
|
449
|
+
variant="inline"
|
|
450
|
+
align="left"
|
|
451
|
+
description={t().providersFailed}
|
|
452
|
+
action={
|
|
453
|
+
<Button size="sm" variant="secondary" onClick={props.retryProviders}>
|
|
454
|
+
{t().retry}
|
|
455
|
+
</Button>
|
|
456
|
+
}
|
|
457
|
+
/>
|
|
458
|
+
</Match>
|
|
459
|
+
</Switch>
|
|
460
|
+
</Match>
|
|
461
|
+
<Match when={status() === "loading"}>
|
|
462
|
+
<Placeholder state="loading" variant="panel" class="cloud-file-chooser__state" description={t().loading} />
|
|
463
|
+
</Match>
|
|
464
|
+
<Match when={status() === "error" && errorView()}>
|
|
465
|
+
{(view) => (
|
|
466
|
+
<Placeholder
|
|
467
|
+
state="error"
|
|
468
|
+
variant="panel"
|
|
469
|
+
class="cloud-file-chooser__state"
|
|
470
|
+
title={view().title}
|
|
471
|
+
description={view().description}
|
|
472
|
+
action={
|
|
473
|
+
view().retry ? (
|
|
474
|
+
<Button size="sm" variant="secondary" onClick={() => void load()}>
|
|
475
|
+
{t().retry}
|
|
476
|
+
</Button>
|
|
477
|
+
) : (
|
|
478
|
+
<Button size="sm" variant="secondary" onClick={up}>
|
|
479
|
+
<i class="ti ti-arrow-up" aria-hidden="true" /> {t().up}
|
|
480
|
+
</Button>
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
/>
|
|
484
|
+
)}
|
|
485
|
+
</Match>
|
|
486
|
+
<Match when={items().length === 0 && !next()}>
|
|
487
|
+
<Show
|
|
488
|
+
when={query()}
|
|
489
|
+
fallback={<Placeholder variant="panel" class="cloud-file-chooser__state" icon="ti ti-folder" title={t().emptyFolder} />}
|
|
490
|
+
>
|
|
491
|
+
<Placeholder
|
|
492
|
+
variant="panel"
|
|
493
|
+
class="cloud-file-chooser__state"
|
|
494
|
+
icon="ti ti-filter-off"
|
|
495
|
+
title={t().noMatches({ query: query() })}
|
|
496
|
+
action={
|
|
497
|
+
<Button size="sm" variant="secondary" onClick={clearFilter}>
|
|
498
|
+
{t().clearFilter}
|
|
499
|
+
</Button>
|
|
500
|
+
}
|
|
501
|
+
/>
|
|
502
|
+
</Show>
|
|
503
|
+
</Match>
|
|
504
|
+
<Match when={true}>
|
|
505
|
+
<FileGrid
|
|
506
|
+
rows={items()}
|
|
507
|
+
getRowId={(entry) => entry.id}
|
|
508
|
+
selection={selection}
|
|
509
|
+
label={t().entries({ folder: folderName() })}
|
|
510
|
+
layout="list"
|
|
511
|
+
renderPreview={(entry) => (
|
|
512
|
+
<i
|
|
513
|
+
class={`${selection.selected().has(entry.id) && entry.kind === "file" ? "ti ti-circle-check" : entryIcon(entry)} cloud-file-chooser__icon`}
|
|
514
|
+
data-selected={selection.selected().has(entry.id) && entry.kind === "file" ? "true" : undefined}
|
|
515
|
+
aria-hidden="true"
|
|
516
|
+
/>
|
|
517
|
+
)}
|
|
518
|
+
renderLabel={(entry) => <span title={entry.name}>{entry.name}</span>}
|
|
519
|
+
renderMeta={(entry) =>
|
|
520
|
+
entry.kind === "folder" && !entry.tags?.length ? null : (
|
|
521
|
+
<span class="cloud-file-chooser__meta">
|
|
522
|
+
<Switch>
|
|
523
|
+
<Match when={entry.kind === "file" && problem(entry) === "size"}>
|
|
524
|
+
<span>{t().tooLarge({ limit: text.pprintBytes(limit(), { locale: locale() }) })}</span>
|
|
525
|
+
</Match>
|
|
526
|
+
<Match when={entry.kind === "file" && problem(entry) === "type"}>
|
|
527
|
+
<span>{t().wrongType}</span>
|
|
528
|
+
</Match>
|
|
529
|
+
<Match when={entry.kind === "file" ? entry : undefined}>
|
|
530
|
+
{(file) => (
|
|
531
|
+
<>
|
|
532
|
+
<Format.Bytes value={file().size} />
|
|
533
|
+
<Show when={file().updatedAt}>
|
|
534
|
+
{(updated) => (
|
|
535
|
+
<>
|
|
536
|
+
<span aria-hidden="true">·</span>
|
|
537
|
+
<Format.Date value={updated()} timeZone={localTimeZone()} />
|
|
538
|
+
</>
|
|
539
|
+
)}
|
|
540
|
+
</Show>
|
|
541
|
+
</>
|
|
542
|
+
)}
|
|
543
|
+
</Match>
|
|
544
|
+
</Switch>
|
|
545
|
+
{renderTags(entry.tags)}
|
|
546
|
+
</span>
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
onRowClick={(entry) => {
|
|
550
|
+
if (entry.kind === "folder") openFolder(entry);
|
|
551
|
+
}}
|
|
552
|
+
onOpen={(entry) => {
|
|
553
|
+
if (entry.kind === "folder") return openFolder(entry);
|
|
554
|
+
if (!selection.selected().has(entry.id)) selection.toggle(entry.id);
|
|
555
|
+
void add();
|
|
556
|
+
}}
|
|
557
|
+
/>
|
|
558
|
+
<div class="cloud-file-chooser__more">
|
|
559
|
+
<Show when={error() && status() === "ready"}>
|
|
560
|
+
<Placeholder state="error" variant="inline" align="left" description={errorView()?.title} />
|
|
561
|
+
</Show>
|
|
562
|
+
<Show when={next()}>
|
|
563
|
+
<Button variant="secondary" size="sm" loading={status() === "more"} onClick={() => void load(true)}>
|
|
564
|
+
{error() ? t().retry : t().loadMore}
|
|
565
|
+
</Button>
|
|
566
|
+
</Show>
|
|
567
|
+
</div>
|
|
568
|
+
</Match>
|
|
569
|
+
</Switch>
|
|
570
|
+
</PanelDialog.Body>
|
|
571
|
+
<PanelDialog.Footer>
|
|
572
|
+
<span class="cloud-file-chooser__status" role="status">
|
|
573
|
+
<Switch>
|
|
574
|
+
<Match when={downloading()}>{progress()}</Match>
|
|
575
|
+
<Match when={!location()}>{null}</Match>
|
|
576
|
+
<Match when={overBudget()}>{t().tooLargeTogether({ limit: text.pprintBytes(budget, { locale: locale() }) })}</Match>
|
|
577
|
+
<Match when={chosen().length > 0}>{t().selected({ count: chosen().length })}</Match>
|
|
578
|
+
<Match when={true}>{t().selectHint({ multiple })}</Match>
|
|
579
|
+
</Switch>
|
|
580
|
+
</span>
|
|
581
|
+
{/* Labels never change, so the buttons keep their place. Cancel stops a transfer first; Add tries failed files again. */}
|
|
582
|
+
<div class="cloud-file-chooser__actions">
|
|
583
|
+
<Button variant="ghost" data-file-chooser-cancel onClick={() => (downloading() ? stop() : props.close([]))}>
|
|
584
|
+
{t().cancel}
|
|
585
|
+
</Button>
|
|
586
|
+
<Show when={location()}>
|
|
587
|
+
<Button
|
|
588
|
+
variant="primary"
|
|
589
|
+
disabled={chosen().length === 0 || overBudget() || (downloading() && !failed())}
|
|
590
|
+
onClick={() => void add()}
|
|
591
|
+
>
|
|
592
|
+
{t().add}
|
|
593
|
+
</Button>
|
|
594
|
+
</Show>
|
|
595
|
+
</div>
|
|
596
|
+
</PanelDialog.Footer>
|
|
597
|
+
</div>
|
|
598
|
+
</PanelDialog>
|
|
599
|
+
);
|
|
600
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { chooseFiles } from "./files";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One "Attach" button that calls the real `chooseFiles` for a browser test, which answers the catalog, the
|
|
5
|
+
* provider's Queries, and its read streams. `?single` chooses one file, `?accept=` and `?max=` pass through.
|
|
6
|
+
* Each call gets a fresh signal that `window.abortChoosing()` aborts. Each result lands in `window.chosen` as plain
|
|
7
|
+
* facts.
|
|
8
|
+
*/
|
|
9
|
+
const params = new URLSearchParams(location.search);
|
|
10
|
+
type Chosen = { name: string; type: string; size: number; text: string }[];
|
|
11
|
+
const results: Chosen[] = [];
|
|
12
|
+
let choosing = new AbortController();
|
|
13
|
+
Object.assign(window, { chosen: results, abortChoosing: () => choosing.abort() });
|
|
14
|
+
|
|
15
|
+
const button = document.createElement("button");
|
|
16
|
+
button.type = "button";
|
|
17
|
+
button.id = "attach";
|
|
18
|
+
button.textContent = "Attach";
|
|
19
|
+
document.body.append(button);
|
|
20
|
+
button.addEventListener("click", () => {
|
|
21
|
+
choosing = new AbortController();
|
|
22
|
+
void chooseFiles({
|
|
23
|
+
multiple: !params.has("single"),
|
|
24
|
+
accept: params.get("accept") ?? undefined,
|
|
25
|
+
maxBytes: params.has("max") ? Number(params.get("max")) : undefined,
|
|
26
|
+
signal: choosing.signal,
|
|
27
|
+
}).then(async (files) => {
|
|
28
|
+
results.push(
|
|
29
|
+
await Promise.all(files.map(async (file) => ({ name: file.name, type: file.type, size: file.size, text: await file.text() }))),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|