@k2b/cloud 0.23.0 → 0.24.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 +2 -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/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/migrate.ts +35 -16
- package/src/ai/runtime.ts +1 -6
- package/src/ai/solid.ts +0 -5
- 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/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- 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/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/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
|
@@ -1,17 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Button, IconButton, ScrollArea, useLocale } from "@k2b/ui";
|
|
1
|
+
import { timed } from "@k2b/stdlib/solid";
|
|
2
|
+
import { announce, Button, IconButton, ScrollArea, useLocale } from "@k2b/ui";
|
|
3
3
|
import { createEffect, createMemo, createSignal, createUniqueId, For, on, onCleanup, onMount, Show } from "solid-js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { SearchApp, SearchItem } from "../api/search/schemas";
|
|
5
5
|
import type { CloudResourceRef } from "../contracts";
|
|
6
|
-
import { LOCALE_HEADER } from "../shared/locale";
|
|
7
6
|
import { shortcutLabel } from "./command-shortcuts";
|
|
8
7
|
import { matchNavigationSearchItems, type NavigationSearchItem } from "./navigation-search";
|
|
9
8
|
import { cloudResourceSearchUrl, filterCloudResourceSearchItems } from "./resource-search";
|
|
10
9
|
import { commitTypedTags, matchingSearchTags, searchTags, tagAtCursor } from "./resource-search-input";
|
|
11
10
|
import { resourceSearchMessages } from "./resource-search-messages";
|
|
12
11
|
import type { GlobalSearchOptions, SearchScope } from "./search-bridge";
|
|
13
|
-
|
|
14
12
|
import { commandSearchItem, matchingCommands, type PaletteCommand } from "./search-commands";
|
|
13
|
+
import {
|
|
14
|
+
applySearchLine,
|
|
15
|
+
breakSearchRun,
|
|
16
|
+
emptySearchRun,
|
|
17
|
+
failedSearchApps,
|
|
18
|
+
retrySearchApp,
|
|
19
|
+
type SearchBlock,
|
|
20
|
+
type SearchRun,
|
|
21
|
+
searchFinished,
|
|
22
|
+
searchingApps,
|
|
23
|
+
streamCloudResourceSearch,
|
|
24
|
+
} from "./search-stream";
|
|
15
25
|
|
|
16
26
|
export type CloudResourceSearchProps = {
|
|
17
27
|
commands?: readonly PaletteCommand[];
|
|
@@ -37,6 +47,7 @@ export type CloudResourceSearchProps = {
|
|
|
37
47
|
const isLinkableCommand = (command: PaletteCommand) => typeof command.action !== "function" && "command" in command.action;
|
|
38
48
|
|
|
39
49
|
const itemKey = (item: SearchItem) => `${item.ref.type}:${item.ref.id}`;
|
|
50
|
+
const URL_BASE = "https://cloud.invalid";
|
|
40
51
|
const groupByApp = (items: SearchItem[]) => {
|
|
41
52
|
const groups = new Map<string, SearchItem[]>();
|
|
42
53
|
for (const item of items) {
|
|
@@ -59,7 +70,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
59
70
|
const appId = () => scope()?.appId ?? scope()?.ref?.type.split(".")[0];
|
|
60
71
|
const scopeLabel = () =>
|
|
61
72
|
!props.request && props.initialAppId
|
|
62
|
-
? (
|
|
73
|
+
? (catalogApps()?.find((app) => app.id === props.initialAppId)?.name ?? scope()?.label)
|
|
63
74
|
: scope()?.label;
|
|
64
75
|
createEffect(() => {
|
|
65
76
|
const request = props.request;
|
|
@@ -114,23 +125,80 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
114
125
|
}),
|
|
115
126
|
);
|
|
116
127
|
const [searchUrl, setSearchUrl] = createSignal(desiredUrl());
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
128
|
+
// One streamed run per search URL. Apps answer in any order; their rows are appended and never reordered.
|
|
129
|
+
const [run, setRun] = createSignal<SearchRun>(emptySearchRun());
|
|
130
|
+
const [runUrl, setRunUrl] = createSignal<string>();
|
|
131
|
+
const [catalogApps, setCatalogApps] = createSignal<SearchApp[]>();
|
|
132
|
+
// The last rows a run showed stay until the next run shows its first rows, so typing does not blank the list.
|
|
133
|
+
const [stale, setStale] = createSignal<{ url: string; blocks: SearchBlock[] }>();
|
|
134
|
+
/** The run shows its own rows, or that it found none, instead of the stale ones. */
|
|
135
|
+
const shownRun = () => run().blocks.length > 0 || searchFinished(run());
|
|
136
|
+
let controller: AbortController | undefined;
|
|
137
|
+
const startRun = (url: string) => {
|
|
138
|
+
controller?.abort();
|
|
139
|
+
const own = new AbortController();
|
|
140
|
+
controller = own;
|
|
141
|
+
const previous = runUrl();
|
|
142
|
+
// What is on screen stays: the last run's rows, or none after it found nothing; a run that showed nothing yet
|
|
143
|
+
// leaves the rows before it.
|
|
144
|
+
if (previous && shownRun()) setStale({ url: previous, blocks: run().blocks });
|
|
145
|
+
setRunUrl(url);
|
|
146
|
+
if (props.searchResources === false) {
|
|
147
|
+
setCatalogApps([]);
|
|
148
|
+
setRun({ ...emptySearchRun(), done: true });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
setRun(emptySearchRun());
|
|
152
|
+
void streamCloudResourceSearch(url, {
|
|
153
|
+
signal: own.signal,
|
|
154
|
+
locale: locale(),
|
|
155
|
+
onLine: (line) => {
|
|
156
|
+
if (controller !== own) return;
|
|
157
|
+
if (line.type === "start") setCatalogApps(line.apps);
|
|
158
|
+
setRun((current) => applySearchLine(current, line));
|
|
159
|
+
},
|
|
160
|
+
}).catch(() => {
|
|
161
|
+
if (controller === own && !own.signal.aborted) setRun(breakSearchRun);
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
/** Searches one app again within the current run; its rows, if any, are appended at the end. */
|
|
165
|
+
const retryApp = (appId: string) => {
|
|
166
|
+
const own = controller;
|
|
167
|
+
const url = runUrl();
|
|
168
|
+
if (!own || !url) return;
|
|
169
|
+
const target = new URL(url, URL_BASE);
|
|
170
|
+
target.searchParams.set("app", appId);
|
|
171
|
+
// Narrowed to one app, the server samples more; the retried section keeps the size the others have.
|
|
172
|
+
const limit = run().providers.length > 1 ? Number(target.searchParams.get("provider_limit")) : undefined;
|
|
173
|
+
setRun((current) => retrySearchApp(current, appId));
|
|
174
|
+
const giveUp = () => {
|
|
175
|
+
if (controller === own && run().status[appId] === "searching")
|
|
176
|
+
setRun((current) => applySearchLine(current, { type: "provider", provider: appId, status: "error", results: [], ms: 0 }));
|
|
177
|
+
};
|
|
178
|
+
void streamCloudResourceSearch(`${target.pathname}${target.search}`, {
|
|
179
|
+
signal: own.signal,
|
|
180
|
+
locale: locale(),
|
|
181
|
+
onLine: (line) => {
|
|
182
|
+
if (controller !== own || line.type !== "provider" || line.provider !== appId) return;
|
|
183
|
+
setRun((current) => applySearchLine(current, { ...line, results: line.results.slice(0, limit) }));
|
|
184
|
+
},
|
|
185
|
+
}).then(giveUp, giveUp);
|
|
186
|
+
};
|
|
187
|
+
createEffect(on(searchUrl, startRun));
|
|
188
|
+
onCleanup(() => controller?.abort());
|
|
189
|
+
|
|
190
|
+
const current = () => runUrl() === desiredUrl();
|
|
191
|
+
const started = () => run().providers.length > 0 || run().done || run().failed;
|
|
192
|
+
const finished = () => current() && searchFinished(run());
|
|
193
|
+
const waiting = () => searchUrl() !== desiredUrl() || !started();
|
|
194
|
+
const pendingApps = () => (current() ? searchingApps(run()) : []);
|
|
195
|
+
const failedApps = () => (current() ? failedSearchApps(run()) : []);
|
|
196
|
+
const appName = (appId: string) => run().apps.find((app) => app.id === appId)?.name ?? appId;
|
|
197
|
+
const failureText = (failure: ReturnType<typeof failedSearchApps>[number]) =>
|
|
198
|
+
failure.status === "timeout" ? t().appTimedOut({ app: appName(failure.appId) }) : t().appUnavailable({ app: appName(failure.appId) });
|
|
199
|
+
// Only a search the user narrowed with the app chip or a tag names its one app when nothing matched.
|
|
200
|
+
const narrowedApp = () => ((Boolean(appId()) || tags().length > 0) && run().providers.length === 1 ? run().providers[0] : undefined);
|
|
201
|
+
const catalog = createMemo(() => searchTags(catalogApps() ?? [], appId()));
|
|
134
202
|
const quickTags = createMemo(() =>
|
|
135
203
|
catalog()
|
|
136
204
|
.filter((tag, index, all) => all.findIndex((other) => other.appName === tag.appName) === index)
|
|
@@ -147,31 +215,64 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
147
215
|
})
|
|
148
216
|
: [],
|
|
149
217
|
);
|
|
218
|
+
// Keep last-good rows only within the same context, never across scope changes.
|
|
219
|
+
const sameScope = (url: string) => {
|
|
220
|
+
const loaded = new URL(url, URL_BASE).searchParams;
|
|
221
|
+
return (
|
|
222
|
+
(loaded.get("scope_type") ?? undefined) === scope()?.ref?.type &&
|
|
223
|
+
(loaded.get("scope_id") ?? undefined) === scope()?.ref?.id &&
|
|
224
|
+
(loaded.get("app") ?? undefined) === appId() &&
|
|
225
|
+
(loaded.get("scope_tag") ?? undefined) === scope()?.tag
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
const shownBlocks = () => {
|
|
229
|
+
const source = shownRun() ? { url: runUrl() ?? "", blocks: run().blocks } : stale();
|
|
230
|
+
return source && sameScope(source.url) ? source.blocks : [];
|
|
231
|
+
};
|
|
232
|
+
/** Rows of the run for the current input; anything older is visible but cannot be chosen. */
|
|
233
|
+
const freshResults = () => current() && shownRun();
|
|
234
|
+
const results = createMemo(() =>
|
|
235
|
+
canSearch()
|
|
236
|
+
? [...groupByApp(navigation()), ...shownBlocks().flatMap((block) => filterCloudResourceSearchItems(block.items, props))]
|
|
237
|
+
: [],
|
|
238
|
+
);
|
|
239
|
+
const searchContext = () => JSON.stringify([desiredUrl(), commandMode(), commandQuery()]);
|
|
240
|
+
/**
|
|
241
|
+
* Actions come first. One that starts to match after rows below the actions are on screen, such as from a catalog
|
|
242
|
+
* that loaded late, waits for the next keystroke instead of pushing those rows down.
|
|
243
|
+
*/
|
|
244
|
+
const shownCommands = createMemo<{ context: string; commands: PaletteCommand[] }>((previous) => {
|
|
245
|
+
const context = searchContext();
|
|
246
|
+
const matching = visibleCommands();
|
|
247
|
+
if (previous?.context !== context || results().length === 0) return { context, commands: matching };
|
|
248
|
+
return { context, commands: matching.filter((command) => previous.commands.some((shown) => shown.id === command.id)) };
|
|
249
|
+
});
|
|
150
250
|
const items = createMemo(() => {
|
|
151
|
-
const commands =
|
|
152
|
-
|
|
153
|
-
// Keep last-good rows only within the same context, never across scope changes.
|
|
154
|
-
const loaded = new URL(searchQuery.data()?.url ?? "/api/search", "https://cloud.invalid");
|
|
155
|
-
const sameScope =
|
|
156
|
-
(loaded.searchParams.get("scope_type") ?? undefined) === scope()?.ref?.type &&
|
|
157
|
-
(loaded.searchParams.get("scope_id") ?? undefined) === scope()?.ref?.id &&
|
|
158
|
-
(loaded.searchParams.get("app") ?? undefined) === appId() &&
|
|
159
|
-
(loaded.searchParams.get("scope_tag") ?? undefined) === scope()?.tag;
|
|
160
|
-
const resources = filterCloudResourceSearchItems(sameScope ? (response()?.items ?? []) : [], props).sort(
|
|
161
|
-
(a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.title.localeCompare(b.title),
|
|
251
|
+
const commands = shownCommands().commands.map((command) =>
|
|
252
|
+
commandSearchItem(command, command.context ? t().contextActions : t().actions),
|
|
162
253
|
);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return textQuery() ? [...results, ...commands] : [...commands, ...results];
|
|
254
|
+
// Instant rows come first: streamed app sections are appended below them and never push them down.
|
|
255
|
+
return [...commands, ...results()];
|
|
166
256
|
});
|
|
167
257
|
const selectable = (item: SearchItem) =>
|
|
168
|
-
!props.disabled &&
|
|
258
|
+
!props.disabled &&
|
|
259
|
+
!choosingTag() &&
|
|
260
|
+
(commandFor(item) || navigation().includes(item) || (freshResults() && searchUrl() === desiredUrl()));
|
|
169
261
|
const activeItem = () => items()[activeIndex()];
|
|
170
262
|
const selectedItem = () => items().find((item) => itemKey(item) === selectedKey());
|
|
171
263
|
const previewItem = () => (props.selectionMode ? (selectedItem() ?? activeItem()) : activeItem());
|
|
172
264
|
const showList = () => !choosingTag() && items().length > 0;
|
|
173
|
-
|
|
174
|
-
const
|
|
265
|
+
// An active search keeps the full height from its first keystroke, so arriving rows never resize the dialog.
|
|
266
|
+
const searching = () => !choosingTag() && (canSearch() || commandMode());
|
|
267
|
+
const showResults = () => showList() && searching();
|
|
268
|
+
const unknownTags = () => (current() && started() ? run().unsupportedTags : []);
|
|
269
|
+
const noMatches = () =>
|
|
270
|
+
!items().length && (commandMode() ? !props.commandsLoading : canSearch() && finished() && !run().failed && failedApps().length === 0);
|
|
271
|
+
const stillSearching = () => {
|
|
272
|
+
const names = pendingApps().map(appName);
|
|
273
|
+
const shown = names.length > 3 ? [...names.slice(0, 2), t().moreApps({ count: names.length - 2 })] : names;
|
|
274
|
+
return t().stillSearching({ apps: new Intl.ListFormat(locale(), { type: "conjunction" }).format(shown), count: names.length });
|
|
275
|
+
};
|
|
175
276
|
|
|
176
277
|
const { debouncedFn: scheduleSearch, cancel } = timed.debounce((url: string) => setSearchUrl(url), 200);
|
|
177
278
|
createEffect(() => {
|
|
@@ -197,7 +298,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
197
298
|
});
|
|
198
299
|
createEffect(
|
|
199
300
|
on(
|
|
200
|
-
() => ({ rows: items(), context:
|
|
301
|
+
() => ({ rows: items(), context: searchContext() }),
|
|
201
302
|
(current, previous) => {
|
|
202
303
|
if (userSelected && previous?.context === current.context) {
|
|
203
304
|
const selected = previous.rows[activeIndex()];
|
|
@@ -207,11 +308,24 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
207
308
|
} else {
|
|
208
309
|
userSelected = false;
|
|
209
310
|
const explicitCommand = commandMode() && Boolean(commandQuery());
|
|
210
|
-
|
|
311
|
+
const firstResult = current.rows.findIndex((row) => row.ref.type !== "cloud.command");
|
|
312
|
+
// Actions are never implicitly selected; with text, the first result is.
|
|
313
|
+
setActiveIndex(explicitCommand ? 0 : current.rows[0]?.ref.type === "cloud.command" && !textQuery() ? -1 : firstResult);
|
|
211
314
|
}
|
|
212
315
|
},
|
|
213
316
|
),
|
|
214
317
|
);
|
|
318
|
+
createEffect(
|
|
319
|
+
on(
|
|
320
|
+
() => canSearch() && !commandMode() && finished(),
|
|
321
|
+
(done, wasDone) => {
|
|
322
|
+
if (!done || wasDone) return;
|
|
323
|
+
// Apps that did not answer are part of the outcome, so a missing answer never sounds like “not found”.
|
|
324
|
+
if (run().failed) announce(t().searchFailed);
|
|
325
|
+
else announce([t().searchDone({ count: results().length }), ...failedApps().map(failureText)].join(". "));
|
|
326
|
+
},
|
|
327
|
+
),
|
|
328
|
+
);
|
|
215
329
|
createEffect(() => {
|
|
216
330
|
suggestions();
|
|
217
331
|
setTagIndex(0);
|
|
@@ -349,7 +463,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
349
463
|
role="group"
|
|
350
464
|
aria-label={props.title ?? t().searchCloudResources}
|
|
351
465
|
class="cloud-resource-search"
|
|
352
|
-
classList={{ "has-results":
|
|
466
|
+
classList={{ "has-results": searching(), "is-picker": props.selectionMode, "shows-details": mobileDetails() && showResults() }}
|
|
353
467
|
onKeyDown={(event) => {
|
|
354
468
|
if (event.target === inputRef) return;
|
|
355
469
|
if (event.key === "Escape") handleKeyDown(event);
|
|
@@ -366,7 +480,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
366
480
|
</IconButton>
|
|
367
481
|
<label class="cloud-resource-search__input">
|
|
368
482
|
<Show
|
|
369
|
-
when={commandMode() ? props.commandsLoading :
|
|
483
|
+
when={commandMode() ? props.commandsLoading : canSearch() && waiting() && !choosingTag()}
|
|
370
484
|
fallback={<i class="ti ti-search" aria-hidden="true" />}
|
|
371
485
|
>
|
|
372
486
|
<i class="ti ti-loader-2 animate-spin" role="status" aria-label={t().loading} />
|
|
@@ -446,7 +560,11 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
446
560
|
/>
|
|
447
561
|
</div>
|
|
448
562
|
</label>
|
|
449
|
-
<ScrollArea
|
|
563
|
+
<ScrollArea
|
|
564
|
+
ref={bodyRef}
|
|
565
|
+
class="cloud-resource-search__body"
|
|
566
|
+
aria-busy={canSearch() && !choosingTag() && (waiting() || pendingApps().length > 0)}
|
|
567
|
+
>
|
|
450
568
|
<Show
|
|
451
569
|
when={choosingTag()}
|
|
452
570
|
fallback={
|
|
@@ -458,25 +576,12 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
458
576
|
</p>
|
|
459
577
|
)}
|
|
460
578
|
</Show>
|
|
461
|
-
<Show when={searchQuery.error()}>
|
|
462
|
-
<div class="cloud-resource-search__hint" role="status">
|
|
463
|
-
{t().searchFailed}{" "}
|
|
464
|
-
<Button variant="text" size="xs" onClick={() => void searchQuery.refresh()}>
|
|
465
|
-
{t().retry}
|
|
466
|
-
</Button>
|
|
467
|
-
</div>
|
|
468
|
-
</Show>
|
|
469
|
-
<Show when={fresh() && response()?.failedApps?.length}>
|
|
470
|
-
<p class="cloud-resource-search__hint" role="status">
|
|
471
|
-
{t().partialFailure}
|
|
472
|
-
</p>
|
|
473
|
-
</Show>
|
|
474
579
|
<Show when={!canSearch() && !commandMode()}>
|
|
475
580
|
<div class="cloud-resource-search__idle">
|
|
476
581
|
<p>{props.selectionMode ? t().pickerHint : props.searchResources === false ? t().navigationHint : t().startHint}</p>
|
|
477
|
-
<div class="cloud-resource-search__quick" aria-busy={!
|
|
582
|
+
<div class="cloud-resource-search__quick" aria-busy={!catalogApps() && !run().failed}>
|
|
478
583
|
<Show
|
|
479
|
-
when={
|
|
584
|
+
when={catalogApps() || run().failed}
|
|
480
585
|
fallback={<For each={[0, 1, 2]}>{() => <span class="cloud-resource-search__tag-skeleton" aria-hidden="true" />}</For>}
|
|
481
586
|
>
|
|
482
587
|
<For each={quickTags()}>
|
|
@@ -487,7 +592,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
487
592
|
)}
|
|
488
593
|
</For>
|
|
489
594
|
</Show>
|
|
490
|
-
<Show when={!
|
|
595
|
+
<Show when={!catalogApps() || catalog().length > 0}>
|
|
491
596
|
<Button
|
|
492
597
|
variant="ghost"
|
|
493
598
|
size="sm"
|
|
@@ -523,10 +628,10 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
523
628
|
{t().commandFailed}
|
|
524
629
|
</p>
|
|
525
630
|
</Show>
|
|
631
|
+
<Show when={searching()}>
|
|
632
|
+
<div class="cloud-resource-search__count">{items().length > 0 ? t().resultCount({ count: items().length }) : "\u00a0"}</div>
|
|
633
|
+
</Show>
|
|
526
634
|
<Show when={showList()}>
|
|
527
|
-
<div class="cloud-resource-search__count" aria-live="polite">
|
|
528
|
-
{t().resultCount({ count: items().length })}
|
|
529
|
-
</div>
|
|
530
635
|
<section id={`${id}-options`} role="listbox" aria-label={t().searchCloudResources} class="cloud-resource-search__results">
|
|
531
636
|
<For each={items()}>
|
|
532
637
|
{(item, index) => (
|
|
@@ -575,18 +680,43 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
575
680
|
</For>
|
|
576
681
|
</section>
|
|
577
682
|
</Show>
|
|
578
|
-
<Show
|
|
579
|
-
|
|
580
|
-
(
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
683
|
+
<Show when={canSearch() && !commandMode() && (pendingApps().length > 0 || failedApps().length > 0)}>
|
|
684
|
+
<div class="cloud-resource-search__status">
|
|
685
|
+
<Show when={pendingApps().length > 0}>
|
|
686
|
+
<p>
|
|
687
|
+
<i class="ti ti-loader-2 animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
|
688
|
+
{stillSearching()}
|
|
689
|
+
</p>
|
|
690
|
+
</Show>
|
|
691
|
+
<For each={failedApps()}>
|
|
692
|
+
{(failure) => (
|
|
693
|
+
<p>
|
|
694
|
+
{failureText(failure)}
|
|
695
|
+
<span aria-hidden="true">·</span>
|
|
696
|
+
<Button variant="text" size="xs" onClick={() => retryApp(failure.appId)}>
|
|
697
|
+
{t().retry}
|
|
698
|
+
</Button>
|
|
699
|
+
</p>
|
|
700
|
+
)}
|
|
701
|
+
</For>
|
|
702
|
+
</div>
|
|
703
|
+
</Show>
|
|
704
|
+
{/* Below the rows, so pages and actions already on screen stay where they are. */}
|
|
705
|
+
<Show when={current() && run().failed}>
|
|
706
|
+
<div class="cloud-resource-search__hint" role="status">
|
|
707
|
+
{t().searchFailed}{" "}
|
|
708
|
+
<Button variant="text" size="xs" onClick={() => startRun(searchUrl())}>
|
|
709
|
+
{t().retry}
|
|
710
|
+
</Button>
|
|
711
|
+
</div>
|
|
712
|
+
</Show>
|
|
713
|
+
<Show when={noMatches()}>
|
|
588
714
|
<p class="cloud-resource-search__hint" role="status">
|
|
589
|
-
{unknownTags().length
|
|
715
|
+
{unknownTags().length
|
|
716
|
+
? t().unsupportedTags
|
|
717
|
+
: !commandMode() && narrowedApp()
|
|
718
|
+
? t().appNoMatches({ app: appName(narrowedApp()!), query: textQuery() })
|
|
719
|
+
: t().noMatches}
|
|
590
720
|
</p>
|
|
591
721
|
</Show>
|
|
592
722
|
</>
|
|
@@ -601,9 +731,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
601
731
|
<Show
|
|
602
732
|
when={suggestions().length > 0}
|
|
603
733
|
fallback={
|
|
604
|
-
<p class="cloud-resource-search__hint">
|
|
605
|
-
{searchQuery.loading() ? t().loadingTags : searchQuery.error() ? t().searchFailed : t().noTags}
|
|
606
|
-
</p>
|
|
734
|
+
<p class="cloud-resource-search__hint">{run().failed ? t().searchFailed : catalogApps() ? t().noTags : t().loadingTags}</p>
|
|
607
735
|
}
|
|
608
736
|
>
|
|
609
737
|
<div role="listbox" id={`${id}-options`} aria-label={t().tagSuggestions} class="cloud-resource-search__tags">
|
|
@@ -634,9 +762,9 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
634
762
|
</Show>
|
|
635
763
|
</Show>
|
|
636
764
|
</ScrollArea>
|
|
637
|
-
<Show when={
|
|
765
|
+
<Show when={searching() || (choosingTag() && suggestions().length > 0)}>
|
|
638
766
|
<footer class="cloud-resource-search__footer">
|
|
639
|
-
<Show when={
|
|
767
|
+
<Show when={searching()}>
|
|
640
768
|
<Button
|
|
641
769
|
class="cloud-resource-search__details"
|
|
642
770
|
disabled={!previewItem()}
|
|
@@ -12,7 +12,15 @@ export const resourceSearchMessages = i18n.define({
|
|
|
12
12
|
commandHint: "Choose an action, or type > next time.",
|
|
13
13
|
searchFailed: "Search is currently unavailable. Try again.",
|
|
14
14
|
navigationFailed: "Could not open this result. Try again.",
|
|
15
|
-
|
|
15
|
+
stillSearching: ({ apps, count }: { apps: string; count: number }) =>
|
|
16
|
+
count === 1 ? `${apps} is still searching…` : `${apps} are still searching…`,
|
|
17
|
+
moreApps: ({ count }: { count: number }) => `${count} more`,
|
|
18
|
+
appTimedOut: ({ app }: { app: string }) => `${app} did not respond in time`,
|
|
19
|
+
appUnavailable: ({ app }: { app: string }) => `${app} is not available right now`,
|
|
20
|
+
appNoMatches: ({ app, query }: { app: string; query: string }) =>
|
|
21
|
+
query ? `${app}: no matches for “${query}”` : `${app}: no matches`,
|
|
22
|
+
searchDone: ({ count }: { count: number }) =>
|
|
23
|
+
i18n.plural(count, "en", { one: `Search complete, ${count} result`, other: `Search complete, ${count} results` }),
|
|
16
24
|
searchPlaceholder: "Search Cloud…",
|
|
17
25
|
scopedSearchPlaceholder: "Search…",
|
|
18
26
|
searchCloudResources: "Search Cloud resources",
|
|
@@ -54,7 +62,12 @@ export const resourceSearchMessages = i18n.define({
|
|
|
54
62
|
commandHint: "Wähle eine Aktion – beim nächsten Mal geht auch >.",
|
|
55
63
|
searchFailed: "Suche gerade nicht erreichbar. Versuche es erneut.",
|
|
56
64
|
navigationFailed: "Der Treffer konnte nicht geöffnet werden. Versuche es erneut.",
|
|
57
|
-
|
|
65
|
+
stillSearching: ({ apps, count }) => (count === 1 ? `${apps} sucht noch …` : `${apps} suchen noch …`),
|
|
66
|
+
moreApps: ({ count }) => `${count} weitere`,
|
|
67
|
+
appTimedOut: ({ app }) => `${app} hat nicht rechtzeitig geantwortet`,
|
|
68
|
+
appUnavailable: ({ app }) => `${app} ist gerade nicht erreichbar`,
|
|
69
|
+
appNoMatches: ({ app, query }) => (query ? `${app}: keine Treffer für „${query}“` : `${app}: keine Treffer`),
|
|
70
|
+
searchDone: ({ count }) => `Suche abgeschlossen, ${count} Treffer`,
|
|
58
71
|
searchPlaceholder: "Cloud durchsuchen…",
|
|
59
72
|
scopedSearchPlaceholder: "Durchsuchen…",
|
|
60
73
|
searchCloudResources: "Cloud-Inhalte durchsuchen",
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { SearchApp, SearchItem, SearchProviderStatus, SearchResponse, SearchStreamLine } from "../api/search/schemas";
|
|
2
|
+
import { LOCALE_HEADER } from "../shared/locale";
|
|
3
|
+
|
|
4
|
+
/** Kept local so the browser bundle does not load the server-side search schemas. */
|
|
5
|
+
const STREAM_CONTENT_TYPE = "application/x-ndjson";
|
|
6
|
+
|
|
7
|
+
export type SearchAppStatus = SearchProviderStatus | "searching";
|
|
8
|
+
|
|
9
|
+
/** Rows one provider line added. A retried app adds a block of its own at the end. */
|
|
10
|
+
export type SearchBlock = { appId: string; items: SearchItem[] };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One streamed search as the browser shows it. Blocks only ever grow at the end, so nothing already visible moves;
|
|
14
|
+
* `status` says for every app named in `start` whether it is still searching and how it finished.
|
|
15
|
+
*/
|
|
16
|
+
export type SearchRun = {
|
|
17
|
+
apps: SearchApp[];
|
|
18
|
+
providers: string[];
|
|
19
|
+
status: Record<string, SearchAppStatus>;
|
|
20
|
+
blocks: SearchBlock[];
|
|
21
|
+
unsupportedTags: string[];
|
|
22
|
+
/** The stream has ended; a retried app may still search afterwards. */
|
|
23
|
+
done: boolean;
|
|
24
|
+
/** The request failed before any app was named. */
|
|
25
|
+
failed: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const emptySearchRun = (): SearchRun => ({
|
|
29
|
+
apps: [],
|
|
30
|
+
providers: [],
|
|
31
|
+
status: {},
|
|
32
|
+
blocks: [],
|
|
33
|
+
unsupportedTags: [],
|
|
34
|
+
done: false,
|
|
35
|
+
failed: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const itemKey = (item: SearchItem) => `${item.ref.type}\u0000${item.ref.id}`;
|
|
39
|
+
|
|
40
|
+
export const applySearchLine = (run: SearchRun, line: SearchStreamLine): SearchRun => {
|
|
41
|
+
if (line.type === "start")
|
|
42
|
+
return {
|
|
43
|
+
...run,
|
|
44
|
+
apps: line.apps,
|
|
45
|
+
providers: line.providers,
|
|
46
|
+
status: Object.fromEntries(line.providers.map((appId) => [appId, "searching" as const])),
|
|
47
|
+
unsupportedTags: line.unsupportedTags ?? [],
|
|
48
|
+
};
|
|
49
|
+
if (line.type === "done") return { ...run, done: true };
|
|
50
|
+
// Only an app that is searching takes a line, which bounds the lines a run accepts.
|
|
51
|
+
if (run.status[line.provider] !== "searching") return run;
|
|
52
|
+
const shown = new Set(run.blocks.flatMap((block) => block.items.map(itemKey)));
|
|
53
|
+
const items = line.results.filter((item) => !shown.has(itemKey(item)));
|
|
54
|
+
return {
|
|
55
|
+
...run,
|
|
56
|
+
status: { ...run.status, [line.provider]: line.status },
|
|
57
|
+
blocks: items.length ? [...run.blocks, { appId: line.provider, items }] : run.blocks,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The request failed or the stream broke off. Once apps were named, the rows that arrived stay and every app still
|
|
63
|
+
* searching has failed on its own, with its own retry; before that, the search failed as a whole.
|
|
64
|
+
*/
|
|
65
|
+
export const breakSearchRun = (run: SearchRun): SearchRun =>
|
|
66
|
+
run.providers.length
|
|
67
|
+
? {
|
|
68
|
+
...run,
|
|
69
|
+
done: true,
|
|
70
|
+
status: Object.fromEntries(Object.entries(run.status).map(([appId, status]) => [appId, status === "searching" ? "error" : status])),
|
|
71
|
+
}
|
|
72
|
+
: { ...run, failed: true };
|
|
73
|
+
|
|
74
|
+
/** Marks an app as searching again before its retry starts. */
|
|
75
|
+
export const retrySearchApp = (run: SearchRun, appId: string): SearchRun => ({
|
|
76
|
+
...run,
|
|
77
|
+
status: { ...run.status, [appId]: "searching" },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const searchingApps = (run: SearchRun) => run.providers.filter((appId) => run.status[appId] === "searching");
|
|
81
|
+
export const failedSearchApps = (run: SearchRun) =>
|
|
82
|
+
run.providers.flatMap((appId) => {
|
|
83
|
+
const status = run.status[appId];
|
|
84
|
+
return status === "timeout" || status === "error" ? [{ appId, status }] : [];
|
|
85
|
+
});
|
|
86
|
+
/** Every app has answered: only now may an empty run say that nothing was found. */
|
|
87
|
+
export const searchFinished = (run: SearchRun) => (run.done || run.failed) && searchingApps(run).length === 0;
|
|
88
|
+
|
|
89
|
+
/** An older Core answers with merged JSON; it becomes the same lines, without per-app timing. */
|
|
90
|
+
const linesFromResponse = (body: SearchResponse): SearchStreamLine[] => {
|
|
91
|
+
const failed = body.failedApps ?? [];
|
|
92
|
+
const found = [...new Set(body.items.map((item) => item.appId))];
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
type: "start",
|
|
96
|
+
query: body.query,
|
|
97
|
+
apps: body.apps,
|
|
98
|
+
providers: [...new Set([...found, ...failed])],
|
|
99
|
+
...(body.unsupportedTags ? { unsupportedTags: body.unsupportedTags } : {}),
|
|
100
|
+
},
|
|
101
|
+
...found.map((appId): SearchStreamLine => {
|
|
102
|
+
const results = body.items.filter((item) => item.appId === appId);
|
|
103
|
+
return { type: "provider", provider: appId, status: failed.includes(appId) ? "error" : "ok", results, ms: 0 };
|
|
104
|
+
}),
|
|
105
|
+
...failed
|
|
106
|
+
.filter((appId) => !found.includes(appId))
|
|
107
|
+
.map((appId): SearchStreamLine => ({ type: "provider", provider: appId, status: "error", results: [], ms: 0 })),
|
|
108
|
+
{ type: "done", status: failed.length ? "partial" : "complete", count: body.items.length },
|
|
109
|
+
];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Runs one search against `/api/search` and hands over each line as it arrives. Aborting `signal` ends the request,
|
|
114
|
+
* and with it every provider the server still waits for. Rejects when the request fails or the stream breaks off.
|
|
115
|
+
*/
|
|
116
|
+
export const streamCloudResourceSearch = async (
|
|
117
|
+
url: string,
|
|
118
|
+
options: { signal: AbortSignal; locale: string; onLine: (line: SearchStreamLine) => void },
|
|
119
|
+
): Promise<void> => {
|
|
120
|
+
const response = await fetch(url, {
|
|
121
|
+
signal: options.signal,
|
|
122
|
+
headers: { accept: STREAM_CONTENT_TYPE, [LOCALE_HEADER]: options.locale },
|
|
123
|
+
});
|
|
124
|
+
if (!response.ok) throw new Error(`Search failed with ${response.status}`);
|
|
125
|
+
if (!response.headers.get("content-type")?.startsWith(STREAM_CONTENT_TYPE)) {
|
|
126
|
+
const body: SearchResponse = await response.json();
|
|
127
|
+
for (const line of linesFromResponse(body)) options.onLine(line);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!response.body) throw new Error("Search returned no body");
|
|
131
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
132
|
+
let buffer = "";
|
|
133
|
+
let closed = false;
|
|
134
|
+
while (!closed) {
|
|
135
|
+
const chunk = await reader.read();
|
|
136
|
+
if (chunk.done) break;
|
|
137
|
+
buffer += chunk.value;
|
|
138
|
+
let end = buffer.indexOf("\n");
|
|
139
|
+
while (end >= 0) {
|
|
140
|
+
const text = buffer.slice(0, end).trim();
|
|
141
|
+
buffer = buffer.slice(end + 1);
|
|
142
|
+
if (text) {
|
|
143
|
+
const line: SearchStreamLine = JSON.parse(text);
|
|
144
|
+
options.onLine(line);
|
|
145
|
+
if (line.type === "done") closed = true;
|
|
146
|
+
}
|
|
147
|
+
end = buffer.indexOf("\n");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!closed) throw new Error("Search stream ended early");
|
|
151
|
+
};
|