@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.
Files changed (45) hide show
  1. package/package.json +2 -2
  2. package/src/_internal/capabilities.ts +230 -75
  3. package/src/_internal/define-app.ts +12 -3
  4. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  5. package/src/_internal/page-responses.ts +1 -1
  6. package/src/_internal/registry.ts +78 -3
  7. package/src/ai/chat/blocks.tsx +2 -1
  8. package/src/ai/chat/messages.ts +6 -0
  9. package/src/ai/client/controller.ts +65 -20
  10. package/src/ai/client/transport.ts +5 -5
  11. package/src/ai/code-mode-skill.ts +2 -2
  12. package/src/ai/index.ts +0 -7
  13. package/src/ai/live-events.ts +1 -262
  14. package/src/ai/live.ts +31 -2
  15. package/src/ai/migrate.ts +35 -16
  16. package/src/ai/runtime.ts +1 -6
  17. package/src/ai/solid.ts +0 -5
  18. package/src/api/help.ts +2 -2
  19. package/src/api/pwa-phone.ts +10 -3
  20. package/src/api/search/schemas.ts +34 -0
  21. package/src/api/search.ts +118 -50
  22. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  23. package/src/browser/CloudResourceSearch.tsx +208 -80
  24. package/src/browser/resource-search-messages.ts +15 -2
  25. package/src/browser/search-stream.ts +151 -0
  26. package/src/contracts/capabilities.ts +89 -24
  27. package/src/contracts/capability-compatibility.ts +28 -14
  28. package/src/contracts/file-provider.ts +158 -0
  29. package/src/contracts/index.ts +1 -0
  30. package/src/events/live-engine.ts +11 -7
  31. package/src/events/live.ts +32 -32
  32. package/src/server/help.ts +3 -3
  33. package/src/services/index.ts +1 -1
  34. package/src/services/outbox.ts +29 -115
  35. package/src/services/pdf/markdown.ts +22 -2
  36. package/src/services/pwa-devices.ts +74 -8
  37. package/src/shared/markdown/formula.ts +122 -26
  38. package/src/shared/markdown/index.ts +27 -14
  39. package/src/ssr/PwaLayout.tsx +2 -1
  40. package/src/styles/resource-search.css +19 -0
  41. package/src/ai/client/live-connection.ts +0 -141
  42. package/src/ai/live-messages.ts +0 -45
  43. package/src/ai/live-outbox.ts +0 -102
  44. package/src/ai/live-routes.ts +0 -412
  45. package/src/shared/markdown/extensions/info-blocks.ts +0 -79
@@ -1,17 +1,27 @@
1
- import { query, timed } from "@k2b/stdlib/solid";
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 { SearchItem, SearchResponse } from "../api/search/schemas";
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
- ? (response()?.apps.find((app) => app.id === props.initialAppId)?.name ?? scope()?.label)
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
- const searchQuery = query.create({
118
- source: searchUrl,
119
- load: async (url, { abortSignal }) => {
120
- if (props.searchResources === false) {
121
- const data: SearchResponse = { query: "", count: 0, apps: [], items: [] };
122
- return { url, data };
123
- }
124
- const response = await fetch(url, { signal: abortSignal, headers: { [LOCALE_HEADER]: locale() } });
125
- if (!response.ok) throw new Error(t().searchFailed);
126
- const data: SearchResponse = await response.json();
127
- return { url, data };
128
- },
129
- });
130
- const response = () => searchQuery.data()?.data;
131
- const fresh = () => searchQuery.data()?.url === desiredUrl() && !searchQuery.error();
132
- const pending = () => searchUrl() !== desiredUrl() || searchQuery.loading() || searchQuery.refreshing();
133
- const catalog = createMemo(() => searchTags(response()?.apps ?? [], appId()));
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 = visibleCommands().map((command) => commandSearchItem(command, command.context ? t().contextActions : t().actions));
152
- if (!canSearch()) return commands;
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
- const results = [...groupByApp(resources), ...groupByApp(navigation())];
164
- // Context actions stay visible when opening an app-scoped search without text.
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 && !choosingTag() && (commandFor(item) || navigation().includes(item) || (fresh() && !pending()));
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
- const showResults = () => showList() && (canSearch() || commandMode());
174
- const unknownTags = () => (fresh() && !pending() ? (response()?.unsupportedTags ?? []) : []);
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: JSON.stringify([desiredUrl(), commandMode(), commandQuery()]) }),
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
- setActiveIndex(current.rows[0]?.ref.type === "cloud.command" && !explicitCommand ? -1 : 0);
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": showResults(), "is-picker": props.selectionMode, "shows-details": mobileDetails() && showResults() }}
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 : pending() && !choosingTag()}
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 ref={bodyRef} class="cloud-resource-search__body" aria-busy={pending() && !choosingTag()}>
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={!response() && !searchQuery.error()}>
582
+ <div class="cloud-resource-search__quick" aria-busy={!catalogApps() && !run().failed}>
478
583
  <Show
479
- when={response() || searchQuery.error()}
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={!response() || catalog().length > 0}>
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
- when={
580
- (canSearch() || commandMode()) &&
581
- !items().length &&
582
- !pending() &&
583
- fresh() &&
584
- !searchQuery.error() &&
585
- !response()?.failedApps?.length
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 ? t().unsupportedTags : t().noMatches}
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={showResults() || (choosingTag() && suggestions().length > 0)}>
765
+ <Show when={searching() || (choosingTag() && suggestions().length > 0)}>
638
766
  <footer class="cloud-resource-search__footer">
639
- <Show when={showResults()}>
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
- partialFailure: "Some search sources are unavailable. Results may be incomplete.",
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
- partialFailure: "Einige Suchquellen sind nicht erreichbar. Ergebnisse können unvollständig sein.",
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
+ };