@k2b/cloud 0.22.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 +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +90 -7
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +20 -4
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/registry.ts +78 -3
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +80 -21
- package/src/ai/client/transport.ts +47 -3
- 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/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/runtime.ts +1 -6
- package/src/ai/solid.ts +0 -5
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/help.ts +2 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +168 -0
- package/src/api/pwa.ts +179 -0
- 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/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/contracts/app.ts +10 -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 +2 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +830 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/help.ts +3 -3
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +114 -80
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +640 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -23
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +70 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/global.css +6 -0
- package/src/styles/resource-search.css +19 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
- 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 -108
|
@@ -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()}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { PWA_AUTH_PATH, PWA_SCOPE } from "../contracts/pwa-paths";
|
|
2
|
+
|
|
3
|
+
export type AppSessionRenewal = {
|
|
4
|
+
/** The page has a valid app session now. */
|
|
5
|
+
ok: boolean;
|
|
6
|
+
/** Android shares Chrome's cookies with the app, so the web session there may belong to another account. */
|
|
7
|
+
otherAccount?: { name: string };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Whether this document is a page of the mobile app, whose requests carry the app session. */
|
|
11
|
+
export const insideMobileApp = (): boolean =>
|
|
12
|
+
typeof location !== "undefined" && location.pathname.startsWith(PWA_SCOPE) && !location.pathname.startsWith(`${PWA_AUTH_PATH}/`);
|
|
13
|
+
|
|
14
|
+
const call = async (): Promise<AppSessionRenewal & { renewed: boolean }> => {
|
|
15
|
+
let response: Response;
|
|
16
|
+
try {
|
|
17
|
+
response = await fetch(`${PWA_AUTH_PATH}/session/renew`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
credentials: "same-origin",
|
|
20
|
+
headers: { Accept: "application/json" },
|
|
21
|
+
});
|
|
22
|
+
} catch {
|
|
23
|
+
// Offline or unreachable: the next foreground or request tries again.
|
|
24
|
+
return { ok: false, renewed: false };
|
|
25
|
+
}
|
|
26
|
+
if (response.status === 401) {
|
|
27
|
+
// The phone was removed or its pairing ended. Core has already deleted the device key with this answer, so
|
|
28
|
+
// a reload could only show the generic pairing view; the pairing view for a signed-out phone says why.
|
|
29
|
+
location.replace(`${PWA_SCOPE}?pwa=ended`);
|
|
30
|
+
return { ok: false, renewed: false };
|
|
31
|
+
}
|
|
32
|
+
if (response.status === 403) {
|
|
33
|
+
const body: unknown = await response.json().catch(() => null);
|
|
34
|
+
if (typeof body === "object" && body !== null && "code" in body && body.code === "ACCOUNT_BLOCKED") {
|
|
35
|
+
location.replace(`${PWA_SCOPE}?pwa=blocked`);
|
|
36
|
+
}
|
|
37
|
+
return { ok: false, renewed: false };
|
|
38
|
+
}
|
|
39
|
+
if (!response.ok) return { ok: false, renewed: false };
|
|
40
|
+
// `PwaRenewResultSchema`, read by hand: the typed client on every page must not load the validators.
|
|
41
|
+
const body: unknown = await response.json().catch(() => null);
|
|
42
|
+
if (typeof body !== "object" || body === null || !("renewed" in body) || typeof body.renewed !== "boolean") {
|
|
43
|
+
return { ok: false, renewed: false };
|
|
44
|
+
}
|
|
45
|
+
const other = "otherAccount" in body ? body.otherAccount : undefined;
|
|
46
|
+
const otherAccount =
|
|
47
|
+
typeof other === "object" && other !== null && "name" in other && typeof other.name === "string" ? { name: other.name } : undefined;
|
|
48
|
+
return { ok: true, renewed: body.renewed, otherAccount };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let running: Promise<AppSessionRenewal> | undefined;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Renews the mobile app's session with the phone's device key through Core. One renewal runs at a time in a page,
|
|
55
|
+
* and every caller shares it. An ended pairing or a blocked account leaves for the app's matching Start view.
|
|
56
|
+
*/
|
|
57
|
+
export const renewAppSession = (): Promise<AppSessionRenewal> => {
|
|
58
|
+
running ??= (async () => {
|
|
59
|
+
try {
|
|
60
|
+
const first = await call();
|
|
61
|
+
// After a rotation, a second call presents the new key and retires the old one.
|
|
62
|
+
if (first.renewed) await call();
|
|
63
|
+
return { ok: first.ok, otherAccount: first.otherAccount };
|
|
64
|
+
} finally {
|
|
65
|
+
running = undefined;
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
return running;
|
|
69
|
+
};
|